use std::collections::HashMap;
use std::time::Instant;
#[derive(Debug, Clone, Copy)]
pub struct TokenBucket {
capacity: f64,
tokens: f64,
refill_per_sec: f64,
last: Instant,
}
pub fn parse_rate(s: &str) -> Result<(u32, f64), String> {
let (b, p) = s
.split_once('/')
.ok_or_else(|| format!("want \"<burst>/<per>s\" (e.g. \"20/1s\"), got {s:?}"))?;
let burst: u32 = b
.trim()
.parse()
.map_err(|_| format!("burst must be a count, got {b:?}"))?;
let per = p.trim();
let secs: f64 = match per
.strip_suffix("sec")
.or_else(|| per.strip_suffix('s').filter(|v| !v.ends_with('m')))
.unwrap_or(per)
.trim()
.parse()
{
Ok(v) => v,
Err(_) => crate::config::parse_duration(per)
.map_err(|_| format!("window must be a duration, got {p:?}"))?
.as_secs_f64(),
};
if burst == 0 || !secs.is_finite() || secs <= 0.0 {
return Err(format!("burst and window must be positive, got {s:?}"));
}
Ok((burst, secs))
}
impl TokenBucket {
pub fn new(burst: u32, per_sec: f64) -> TokenBucket {
let capacity = f64::from(burst);
TokenBucket {
capacity,
tokens: capacity,
refill_per_sec: per_sec,
last: Instant::now(),
}
}
pub fn try_take_at(&mut self, now: Instant) -> bool {
let elapsed = now.saturating_duration_since(self.last).as_secs_f64();
self.tokens = (self.tokens + elapsed * self.refill_per_sec).min(self.capacity);
self.last = now;
if self.tokens >= 1.0 {
self.tokens -= 1.0;
true
} else {
false
}
}
pub fn try_take(&mut self) -> bool {
self.try_take_at(Instant::now())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct NodeId(pub u64);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NodeStatus {
Spawning,
Running,
Done,
Failed,
}
#[derive(Debug, Clone)]
pub struct Node {
pub id: NodeId,
pub parent: Option<NodeId>,
pub depth: u32,
pub agent_path: String,
pub status: NodeStatus,
pub tokens: u64,
pub children: Vec<NodeId>,
}
#[derive(Debug, Clone, Copy)]
pub struct Caps {
pub max_depth: u32,
pub max_children: u32,
pub max_total: u32,
pub tree_token_ceiling: u64,
pub spawn_rate_burst: u32,
pub spawn_rate_per_sec: f64,
}
impl Default for Caps {
fn default() -> Self {
Caps {
max_depth: 4,
max_children: 8,
max_total: 64,
tree_token_ceiling: 2_000_000,
spawn_rate_burst: 8,
spawn_rate_per_sec: 2.0,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SpawnRefused {
Draining,
MaxDepth,
MaxChildren,
MaxTotal,
RateExceeded,
TreeBudget,
UnknownParent,
}
impl SpawnRefused {
pub fn as_str(self) -> &'static str {
match self {
SpawnRefused::Draining => "tree is draining; no new subagents",
SpawnRefused::MaxDepth => "max subagent depth reached",
SpawnRefused::MaxChildren => "parent has too many children",
SpawnRefused::MaxTotal => "max total subagents reached",
SpawnRefused::RateExceeded => "spawn rate exceeded",
SpawnRefused::TreeBudget => "tree token budget exhausted",
SpawnRefused::UnknownParent => "unknown parent handle",
}
}
}
pub struct Tree {
nodes: HashMap<NodeId, Node>,
next_id: u64,
root: Option<NodeId>,
draining: bool,
total_tokens: u64,
spawn_bucket: TokenBucket,
caps: Caps,
}
impl Tree {
pub fn new(caps: Caps) -> Tree {
Tree {
nodes: HashMap::new(),
next_id: 0,
root: None,
draining: false,
total_tokens: 0,
spawn_bucket: TokenBucket::new(caps.spawn_rate_burst, caps.spawn_rate_per_sec),
caps,
}
}
pub fn caps(&self) -> Caps {
self.caps
}
pub fn is_draining(&self) -> bool {
self.draining
}
pub fn total_tokens(&self) -> u64 {
self.total_tokens
}
pub fn len(&self) -> usize {
self.nodes.len()
}
pub fn is_empty(&self) -> bool {
self.nodes.is_empty()
}
pub fn get(&self, id: NodeId) -> Option<&Node> {
self.nodes.get(&id)
}
pub fn root(&self) -> Option<NodeId> {
self.root
}
pub fn mint_root(&mut self) -> Result<NodeId, SpawnRefused> {
if self.draining {
return Err(SpawnRefused::Draining);
}
let id = self.alloc(None, 0, "0".to_string());
self.root = Some(id);
Ok(id)
}
pub fn mint_child(&mut self, parent: NodeId) -> Result<NodeId, SpawnRefused> {
if self.draining {
return Err(SpawnRefused::Draining);
}
if self.total_tokens >= self.caps.tree_token_ceiling {
return Err(SpawnRefused::TreeBudget);
}
if self.nodes.len() as u32 >= self.caps.max_total {
return Err(SpawnRefused::MaxTotal);
}
let (depth, child_index, parent_path) = {
let p = self.nodes.get(&parent).ok_or(SpawnRefused::UnknownParent)?;
if p.depth + 1 > self.caps.max_depth {
return Err(SpawnRefused::MaxDepth);
}
if p.children.len() as u32 >= self.caps.max_children {
return Err(SpawnRefused::MaxChildren);
}
(p.depth + 1, p.children.len(), p.agent_path.clone())
};
if !self.spawn_bucket.try_take() {
return Err(SpawnRefused::RateExceeded);
}
let path = format!("{parent_path}.{child_index}");
let id = self.alloc(Some(parent), depth, path);
if let Some(p) = self.nodes.get_mut(&parent) {
p.children.push(id);
}
Ok(id)
}
fn alloc(&mut self, parent: Option<NodeId>, depth: u32, agent_path: String) -> NodeId {
let id = NodeId(self.next_id);
self.next_id += 1;
self.nodes.insert(
id,
Node {
id,
parent,
depth,
agent_path,
status: NodeStatus::Spawning,
tokens: 0,
children: Vec::new(),
},
);
id
}
pub fn set_status(&mut self, id: NodeId, status: NodeStatus) {
if let Some(n) = self.nodes.get_mut(&id) {
n.status = status;
}
}
pub fn charge_tokens(&mut self, id: NodeId, tokens: u64) -> bool {
if let Some(n) = self.nodes.get_mut(&id) {
n.tokens = n.tokens.saturating_add(tokens);
}
self.total_tokens = self.total_tokens.saturating_add(tokens);
self.total_tokens >= self.caps.tree_token_ceiling
}
pub fn set_draining(&mut self) {
self.draining = true;
}
pub fn deepest_first(&self) -> Vec<NodeId> {
let mut ids: Vec<NodeId> = self.nodes.keys().copied().collect();
ids.sort_by(|a, b| {
let da = self.nodes[a].depth;
let db = self.nodes[b].depth;
db.cmp(&da).then(b.cmp(a))
});
ids
}
pub fn remove(&mut self, id: NodeId) -> Option<Node> {
let node = self.nodes.remove(&id)?;
if let Some(p) = node.parent.and_then(|parent| self.nodes.get_mut(&parent)) {
p.children.retain(|c| *c != id);
}
Some(node)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn root_then_child_depth_and_path() {
let mut t = Tree::new(Caps::default());
let root = t.mint_root().unwrap();
assert_eq!(t.get(root).unwrap().depth, 0);
assert_eq!(t.get(root).unwrap().agent_path, "0");
let c0 = t.mint_child(root).unwrap();
let c1 = t.mint_child(root).unwrap();
assert_eq!(t.get(c0).unwrap().depth, 1);
assert_eq!(t.get(c0).unwrap().agent_path, "0.0");
assert_eq!(t.get(c1).unwrap().agent_path, "0.1");
assert_eq!(t.get(root).unwrap().children.len(), 2);
}
#[test]
fn depth_cap_refuses() {
let caps = Caps {
max_depth: 2,
..Caps::default()
};
let mut t = Tree::new(caps);
let root = t.mint_root().unwrap(); let a = t.mint_child(root).unwrap(); let b = t.mint_child(a).unwrap(); assert_eq!(t.mint_child(b).unwrap_err(), SpawnRefused::MaxDepth);
}
#[test]
fn children_cap_refuses() {
let caps = Caps {
max_children: 2,
..Caps::default()
};
let mut t = Tree::new(caps);
let root = t.mint_root().unwrap();
t.mint_child(root).unwrap();
t.mint_child(root).unwrap();
assert_eq!(t.mint_child(root).unwrap_err(), SpawnRefused::MaxChildren);
}
#[test]
fn total_cap_refuses() {
let caps = Caps {
max_total: 2,
max_children: 10,
..Caps::default()
};
let mut t = Tree::new(caps);
let root = t.mint_root().unwrap(); t.mint_child(root).unwrap(); assert_eq!(t.mint_child(root).unwrap_err(), SpawnRefused::MaxTotal);
}
#[test]
fn draining_refuses_new_spawns() {
let mut t = Tree::new(Caps::default());
let root = t.mint_root().unwrap();
t.set_draining();
assert_eq!(t.mint_child(root).unwrap_err(), SpawnRefused::Draining);
}
#[test]
fn token_accounting_rolls_to_root_and_trips_ceiling() {
let caps = Caps {
tree_token_ceiling: 100,
..Caps::default()
};
let mut t = Tree::new(caps);
let root = t.mint_root().unwrap();
let c = t.mint_child(root).unwrap();
assert!(!t.charge_tokens(c, 60));
assert_eq!(t.total_tokens(), 60);
assert!(t.charge_tokens(c, 40)); assert_eq!(t.get(c).unwrap().tokens, 100);
}
#[test]
fn token_bucket_burst_then_refill() {
use std::time::Duration;
let mut b = TokenBucket::new(8, 2.0);
let t0 = Instant::now();
for i in 0..8 {
assert!(b.try_take_at(t0), "burst token {i} should be available");
}
assert!(
!b.try_take_at(t0),
"9th take with no refill must be refused"
);
assert!(!b.try_take_at(t0 + Duration::from_millis(400)));
let t1 = t0 + Duration::from_millis(500);
assert!(b.try_take_at(t1), "one token refills after the interval");
assert!(!b.try_take_at(t1), "and only one — the refill is metered");
}
#[test]
fn token_bucket_caps_at_burst() {
use std::time::Duration;
let mut b = TokenBucket::new(8, 2.0);
let t0 = Instant::now();
let far = t0 + Duration::from_secs(3600);
for _ in 0..8 {
assert!(b.try_take_at(far));
}
assert!(
!b.try_take_at(far),
"no more than `burst` tokens ever accrue"
);
}
#[test]
fn spawn_rate_cap_refuses_after_burst() {
let slow = Caps {
max_children: 100,
max_total: 100,
spawn_rate_burst: 3,
spawn_rate_per_sec: 1.0,
..Caps::default()
};
let mut t = Tree::new(slow);
let root = t.mint_root().unwrap();
t.mint_child(root).unwrap();
t.mint_child(root).unwrap();
t.mint_child(root).unwrap();
assert_eq!(
t.mint_child(root).unwrap_err(),
SpawnRefused::RateExceeded,
"the 4th rapid spawn is rate-limited, not breadth-limited"
);
let fast = Caps {
max_children: 100,
max_total: 100,
spawn_rate_burst: 1,
spawn_rate_per_sec: 1000.0,
..Caps::default()
};
let mut t = Tree::new(fast);
let root = t.mint_root().unwrap();
t.mint_child(root).unwrap();
std::thread::sleep(std::time::Duration::from_millis(50));
assert!(
t.mint_child(root).is_ok(),
"a refilled token re-admits a spawn"
);
}
#[test]
fn deepest_first_orders_children_before_parents() {
let mut t = Tree::new(Caps::default());
let root = t.mint_root().unwrap();
let a = t.mint_child(root).unwrap();
let b = t.mint_child(a).unwrap();
let order = t.deepest_first();
let pos = |id: NodeId| order.iter().position(|x| *x == id).unwrap();
assert!(pos(b) < pos(a));
assert!(pos(a) < pos(root));
}
}