1use std::collections::{HashMap, HashSet, VecDeque};
23use std::time::{Duration, Instant};
24
25use crate::protocol::{AgentCard, AgentSkill};
26
27#[derive(Debug, thiserror::Error)]
29#[non_exhaustive]
30pub enum ScaleError {
31 #[error("delegation would exceed the {max} hop limit at hop {hops}")]
33 HopLimitExceeded {
34 hops: usize,
36 max: usize,
38 },
39 #[error("worker agents may not delegate to other agents")]
41 WorkerToWorker,
42 #[error("task delegation {parent} -> {child} would create a cycle")]
44 CycleDetected {
45 parent: String,
47 child: String,
49 },
50}
51
52#[derive(Debug, Clone)]
54pub struct SkillEntry {
55 pub skill: AgentSkill,
57 pub agent_url: String,
59}
60
61impl SkillEntry {
62 pub fn new(skill: AgentSkill, agent_url: impl Into<String>) -> Self {
64 Self {
65 skill,
66 agent_url: agent_url.into(),
67 }
68 }
69}
70
71#[derive(Debug, Default)]
78pub struct SkillIndex {
79 entries: Vec<SkillEntry>,
80}
81
82impl SkillIndex {
83 pub fn new() -> Self {
85 Self::default()
86 }
87
88 pub fn with_entry(mut self, entry: SkillEntry) -> Self {
90 self.entries.push(entry);
91 self
92 }
93
94 pub fn index_card(&mut self, card: &AgentCard) {
96 for skill in &card.skills {
97 self.entries
98 .push(SkillEntry::new(skill.clone(), card.url.clone()));
99 }
100 }
101
102 pub fn entries(&self) -> &[SkillEntry] {
104 &self.entries
105 }
106
107 pub fn search(&self, query: &str, limit: usize) -> Vec<SkillEntry> {
110 let query_vec = tokens(query);
111 let mut scored: Vec<(f64, &SkillEntry)> = self
112 .entries
113 .iter()
114 .map(|e| (cosine(&query_vec, &tokens(&e.skill.description)), e))
115 .filter(|(score, _)| *score > 0.0)
116 .collect();
117 scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
118 scored.truncate(limit);
119 scored.into_iter().map(|(_, e)| e.clone()).collect()
120 }
121}
122
123fn tokens(text: &str) -> Vec<String> {
125 text.to_lowercase()
126 .split(|c: char| !c.is_alphanumeric())
127 .filter(|w| !w.is_empty())
128 .map(String::from)
129 .collect()
130}
131
132fn cosine(a: &[String], b: &[String]) -> f64 {
135 let mut counts_a: HashMap<&str, usize> = HashMap::new();
136 let mut counts_b: HashMap<&str, usize> = HashMap::new();
137 for t in a {
138 *counts_a.entry(t).or_insert(0) += 1;
139 }
140 for t in b {
141 *counts_b.entry(t).or_insert(0) += 1;
142 }
143 if counts_a.is_empty() || counts_b.is_empty() {
144 return 0.0;
145 }
146 let mut dot = 0.0_f64;
147 let mut norm_a = 0.0_f64;
148 let mut norm_b = 0.0_f64;
149 for (tok, ca) in &counts_a {
150 let cb = counts_b.get(tok).copied().unwrap_or(0);
151 dot += (*ca as f64) * (cb as f64);
152 norm_a += (*ca as f64) * (*ca as f64);
153 }
154 for cb in counts_b.values() {
155 norm_b += (*cb as f64) * (*cb as f64);
156 }
157 if norm_a == 0.0 || norm_b == 0.0 {
158 return 0.0;
159 }
160 dot / (norm_a.sqrt() * norm_b.sqrt())
161}
162
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
165pub enum AgentTier {
166 Orchestrator,
168 Worker,
170}
171
172pub fn may_delegate(from: AgentTier, to: AgentTier) -> bool {
177 use AgentTier::{Orchestrator, Worker};
178 matches!(
179 (from, to),
180 (Orchestrator, Orchestrator) | (Orchestrator, Worker)
181 )
182}
183
184#[derive(Debug, Default)]
187pub struct HierarchyPolicy {
188 orchestrators: HashSet<String>,
189 workers: HashSet<String>,
190}
191
192impl HierarchyPolicy {
193 pub fn new() -> Self {
195 Self::default()
196 }
197
198 pub fn with_orchestrator(mut self, url: impl Into<String>) -> Self {
200 self.orchestrators.insert(url.into());
201 self
202 }
203
204 pub fn with_worker(mut self, url: impl Into<String>) -> Self {
206 self.workers.insert(url.into());
207 self
208 }
209
210 pub fn tier(&self, url: &str) -> AgentTier {
213 if self.orchestrators.contains(url) {
214 AgentTier::Orchestrator
215 } else {
216 AgentTier::Worker
217 }
218 }
219
220 pub fn check_delegation(&self, from: &str, to: &str) -> Result<(), ScaleError> {
222 if may_delegate(self.tier(from), self.tier(to)) {
223 Ok(())
224 } else {
225 Err(ScaleError::WorkerToWorker)
226 }
227 }
228}
229
230#[derive(Debug, Clone, Copy)]
233pub struct DelegationGuard {
234 max_hops: usize,
235}
236
237impl Default for DelegationGuard {
238 fn default() -> Self {
239 Self { max_hops: 10 }
240 }
241}
242
243impl DelegationGuard {
244 pub fn new(max_hops: usize) -> Self {
246 Self { max_hops }
247 }
248
249 pub fn max_hops(&self) -> usize {
251 self.max_hops
252 }
253
254 pub fn check(&self, hops: usize) -> Result<(), ScaleError> {
256 if hops > self.max_hops {
257 Err(ScaleError::HopLimitExceeded {
258 hops,
259 max: self.max_hops,
260 })
261 } else {
262 Ok(())
263 }
264 }
265}
266
267fn fnv1a(bytes: &[u8]) -> u64 {
269 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
270 for b in bytes {
271 hash ^= u64::from(*b);
272 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
273 }
274 hash
275}
276
277#[derive(Debug, Clone, Copy)]
283pub struct TaskSharder {
284 num_shards: usize,
285}
286
287impl TaskSharder {
288 pub fn new(num_shards: usize) -> Self {
290 Self {
291 num_shards: num_shards.max(1),
292 }
293 }
294
295 pub fn shard(&self, task_id: &str) -> usize {
297 (fnv1a(task_id.as_bytes()) % self.num_shards as u64) as usize
298 }
299
300 pub fn shards<'a>(&self, task_ids: impl IntoIterator<Item = &'a str>) -> Vec<usize> {
302 task_ids.into_iter().map(|id| self.shard(id)).collect()
303 }
304
305 pub fn num_shards(&self) -> usize {
307 self.num_shards
308 }
309}
310
311#[derive(Debug, Clone, Copy, PartialEq, Eq)]
313pub enum BreakerState {
314 Closed,
316 Open,
318 HalfOpen,
320}
321
322#[derive(Debug, Clone, Copy)]
324pub struct CircuitBreakerConfig {
325 pub failure_threshold: usize,
327 pub open_duration: Duration,
329}
330
331impl Default for CircuitBreakerConfig {
332 fn default() -> Self {
333 Self {
334 failure_threshold: 5,
335 open_duration: Duration::from_secs(30),
336 }
337 }
338}
339
340pub struct CircuitBreaker {
348 config: CircuitBreakerConfig,
349 inner: std::sync::Mutex<BreakerInner>,
350}
351
352#[derive(Debug)]
353struct BreakerInner {
354 state: BreakerState,
355 consecutive_failures: usize,
356 opened_at: Option<Instant>,
357}
358
359impl CircuitBreaker {
360 pub fn new(config: CircuitBreakerConfig) -> Self {
362 Self {
363 config,
364 inner: std::sync::Mutex::new(BreakerInner {
365 state: BreakerState::Closed,
366 consecutive_failures: 0,
367 opened_at: None,
368 }),
369 }
370 }
371
372 pub fn allow_request(&self) -> bool {
378 let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
379 if inner.state == BreakerState::Open {
380 let reopened = inner
381 .opened_at
382 .is_some_and(|at| at.elapsed() >= self.config.open_duration);
383 if reopened {
384 inner.state = BreakerState::HalfOpen;
385 return true;
386 }
387 return false;
388 }
389 true
390 }
391
392 pub fn record_success(&self) {
394 let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
395 inner.state = BreakerState::Closed;
396 inner.consecutive_failures = 0;
397 inner.opened_at = None;
398 }
399
400 pub fn record_failure(&self) {
402 let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
403 inner.consecutive_failures += 1;
404 if inner.consecutive_failures >= self.config.failure_threshold {
405 inner.state = BreakerState::Open;
406 inner.opened_at = Some(Instant::now());
407 }
408 }
409
410 pub fn state(&self) -> BreakerState {
412 self.inner.lock().unwrap_or_else(|e| e.into_inner()).state
413 }
414}
415
416#[derive(Debug, Clone, Copy)]
423pub struct StickyRouter {
424 slots: usize,
425}
426
427impl StickyRouter {
428 pub fn new(slots: usize) -> Self {
430 Self {
431 slots: slots.max(1),
432 }
433 }
434
435 pub fn route(&self, key: &str) -> usize {
437 (fnv1a(key.as_bytes()) % self.slots as u64) as usize
438 }
439
440 pub fn slots(&self) -> usize {
442 self.slots
443 }
444}
445
446#[derive(Debug, Default)]
453pub struct TaskGraph {
454 children: HashMap<String, Vec<String>>,
455}
456
457impl TaskGraph {
458 pub fn new() -> Self {
460 Self::default()
461 }
462
463 pub fn link(&mut self, parent: &str, child: &str) -> Result<(), ScaleError> {
468 if parent == child || self.would_cycle(parent, child) {
469 return Err(ScaleError::CycleDetected {
470 parent: parent.to_string(),
471 child: child.to_string(),
472 });
473 }
474 self.children
475 .entry(parent.to_string())
476 .or_default()
477 .push(child.to_string());
478 Ok(())
479 }
480
481 pub fn would_cycle(&self, parent: &str, child: &str) -> bool {
484 let mut stack = vec![parent.to_string()];
485 let mut seen = HashSet::new();
486 while let Some(node) = stack.pop() {
487 if node == child {
488 return true;
489 }
490 if !seen.insert(node.clone()) {
491 continue;
492 }
493 for ancestor in self.parents_of(&node) {
494 stack.push(ancestor);
495 }
496 }
497 false
498 }
499
500 fn parents_of(&self, node: &str) -> Vec<String> {
502 self.children
503 .iter()
504 .filter(|(_, kids)| kids.iter().any(|k| k == node))
505 .map(|(parent, _)| parent.clone())
506 .collect()
507 }
508
509 pub fn is_acyclic(&self) -> bool {
511 let mut in_degree: HashMap<String, usize> = HashMap::new();
512 let mut nodes: HashSet<String> = HashSet::new();
513 for (parent, kids) in &self.children {
514 nodes.insert(parent.clone());
515 in_degree.entry(parent.clone()).or_insert(0);
516 for kid in kids {
517 nodes.insert(kid.clone());
518 *in_degree.entry(kid.clone()).or_insert(0) += 1;
519 }
520 }
521 let mut queue: VecDeque<String> = nodes
522 .iter()
523 .filter(|n| in_degree.get(*n).copied().unwrap_or(0) == 0)
524 .cloned()
525 .collect();
526 let mut processed = 0;
527 while let Some(node) = queue.pop_front() {
528 processed += 1;
529 if let Some(kids) = self.children.get(&node) {
530 for kid in kids {
531 let degree = in_degree.get_mut(kid).expect("kid is in in_degree");
532 *degree -= 1;
533 if *degree == 0 {
534 queue.push_back(kid.clone());
535 }
536 }
537 }
538 }
539 processed == nodes.len()
540 }
541}
542
543#[cfg(test)]
544mod tests {
545 use super::*;
546
547 fn skill(id: &str, description: &str) -> AgentSkill {
548 AgentSkill::new(id, id, description)
549 }
550
551 #[test]
552 fn skill_index_ranks_relevant_agent_first() {
553 let mut index = SkillIndex::new();
554 index.index_card(
555 &AgentCard::new("retriever", "doc search", "http://retriever").with_skill(skill(
556 "retrieve",
557 "retrieve relevant documents from the corpus",
558 )),
559 );
560 index.index_card(
561 &AgentCard::new("summarizer", "text", "http://summarizer").with_skill(skill(
562 "summarize",
563 "condense long documents into a short summary",
564 )),
565 );
566
567 let results = index.search("retrieve documents", 2);
568 assert_eq!(results.len(), 2);
569 assert_eq!(results[0].agent_url, "http://retriever");
570
571 let limited = index.search("retrieve documents", 1);
572 assert_eq!(limited.len(), 1);
573 assert_eq!(limited[0].agent_url, "http://retriever");
574 }
575
576 #[test]
577 fn skill_index_returns_nothing_for_unmatched_query() {
578 let mut index = SkillIndex::new();
579 index.index_card(
580 &AgentCard::new("a", "a", "http://a").with_skill(skill("s", "transcribe audio")),
581 );
582 assert!(index.search("orbit physics", 5).is_empty());
583 assert!(index.search("", 5).is_empty());
584 }
585
586 #[test]
587 fn delegation_guard_enforces_hop_limit() {
588 let guard = DelegationGuard::default();
589 assert_eq!(guard.max_hops(), 10);
590 guard.check(0).unwrap();
591 guard.check(10).unwrap();
592 let err = guard.check(11).unwrap_err();
593 assert!(matches!(
594 err,
595 ScaleError::HopLimitExceeded { hops: 11, max: 10 }
596 ));
597 }
598
599 #[test]
600 fn sharder_is_stable_and_bounded() {
601 let sharder = TaskSharder::new(4);
602 assert_eq!(sharder.shard("task-1"), sharder.shard("task-1"));
604 assert_eq!(sharder.shard("task-42"), sharder.shard("task-42"));
605 for id in ["a", "b", "c", "task-xyz"] {
607 assert!(sharder.shard(id) < 4);
608 }
609 assert_eq!(sharder.shards(["a", "b"]).len(), 2);
610 assert_eq!(sharder.num_shards(), 4);
611 }
612
613 #[test]
614 fn sharder_clamps_to_one_shard() {
615 let sharder = TaskSharder::new(0);
616 assert_eq!(sharder.num_shards(), 1);
617 assert_eq!(sharder.shard("anything"), 0);
618 }
619
620 #[test]
621 fn circuit_breaker_trips_open_then_recovers() {
622 let breaker = CircuitBreaker::new(CircuitBreakerConfig {
623 failure_threshold: 3,
624 open_duration: Duration::from_millis(20),
625 });
626
627 assert_eq!(breaker.state(), BreakerState::Closed);
629 assert!(breaker.allow_request());
630 breaker.record_failure();
631 breaker.record_failure();
632 assert!(breaker.allow_request());
633 breaker.record_failure();
634 assert_eq!(breaker.state(), BreakerState::Open);
635 assert!(!breaker.allow_request(), "open breaker must reject calls");
636
637 std::thread::sleep(Duration::from_millis(40));
639 assert!(breaker.allow_request());
640 assert_eq!(breaker.state(), BreakerState::HalfOpen);
641
642 breaker.record_success();
644 assert_eq!(breaker.state(), BreakerState::Closed);
645 assert!(breaker.allow_request());
646 }
647
648 #[test]
649 fn sticky_router_is_deterministic() {
650 let router = StickyRouter::new(3);
651 assert_eq!(router.route("conv-1"), router.route("conv-1"));
652 assert_eq!(router.route("conv-2"), router.route("conv-2"));
653 for key in ["conv-1", "conv-2", "conv-3", "owner:alice"] {
654 assert!(router.route(key) < 3);
655 }
656 assert_eq!(router.slots(), 3);
657 }
658
659 #[test]
660 fn task_graph_accepts_acyclic_chain() {
661 let mut graph = TaskGraph::new();
662 graph.link("root", "child").unwrap();
663 graph.link("child", "grandchild").unwrap();
664 assert!(graph.is_acyclic());
665
666 assert!(!graph.would_cycle("root", "grandchild"));
669 assert!(graph.would_cycle("grandchild", "root"));
671
672 graph.link("root", "grandchild").unwrap();
673 assert!(graph.is_acyclic());
674 }
675
676 #[test]
677 fn task_graph_detects_cycle() {
678 let mut graph = TaskGraph::new();
679 graph.link("a", "b").unwrap();
680 graph.link("b", "c").unwrap();
681
682 let err = graph.link("c", "a").unwrap_err();
684 assert!(
685 matches!(err, ScaleError::CycleDetected { parent, child } if parent == "c" && child == "a")
686 );
687
688 let err = graph.link("x", "x").unwrap_err();
690 assert!(matches!(err, ScaleError::CycleDetected { .. }));
691 }
692
693 #[test]
694 fn task_graph_rejects_cycle_globally() {
695 let mut graph = TaskGraph::new();
696 graph.link("a", "b").unwrap();
697 graph.link("b", "c").unwrap();
698
699 graph
702 .children
703 .entry("c".to_string())
704 .or_default()
705 .push("a".to_string());
706 assert!(graph.would_cycle("c", "a"));
707 assert!(!graph.is_acyclic());
708 }
709
710 #[test]
711 fn hierarchy_allows_orchestrator_but_blocks_worker() {
712 let policy = HierarchyPolicy::new()
713 .with_orchestrator("http://orch")
714 .with_worker("http://worker");
715
716 policy
717 .check_delegation("http://orch", "http://worker")
718 .unwrap();
719 policy
720 .check_delegation("http://orch", "http://orch")
721 .unwrap();
722
723 let err = policy
724 .check_delegation("http://worker", "http://worker")
725 .unwrap_err();
726 assert!(matches!(err, ScaleError::WorkerToWorker));
727 assert!(matches!(
729 policy.check_delegation("http://unknown", "http://worker"),
730 Err(ScaleError::WorkerToWorker)
731 ));
732 }
733}