Skip to main content

laser_sdk/
swarm.rs

1use crate::govern::PolicyEvidence;
2use std::collections::{HashMap, HashSet};
3
4/// One agent's folded activity, so a coordinator answers "what has this agent
5/// been doing" from evidence already read off the audit topic, without
6/// re-deriving counts by hand every time. `by_verdict` counts every decision
7/// this agent triggered, keyed by [`PolicyEvidence::decision`] (`allow`,
8/// `observe`, `block`, `step_up`, `modify`, `defer`).
9#[derive(Debug, Clone, Default, PartialEq)]
10pub struct AgentActivity {
11    /// Every decision folded in for this agent, regardless of verdict.
12    pub decisions: u64,
13    by_verdict: HashMap<String, u64>,
14    /// The most recent decision folded in (by `at_micros`), for a coordinator
15    /// that wants the detail behind the counts.
16    pub last_decision: Option<PolicyEvidence>,
17}
18
19impl AgentActivity {
20    fn fold(&mut self, evidence: &PolicyEvidence) {
21        self.decisions += 1;
22        *self
23            .by_verdict
24            .entry(evidence.decision.clone())
25            .or_insert(0) += 1;
26        if self.last_decision.as_ref().is_none_or(|last| {
27            (last.at_micros, last.decision_id.as_str())
28                < (evidence.at_micros, evidence.decision_id.as_str())
29        }) {
30            self.last_decision = Some(evidence.clone());
31        }
32    }
33
34    /// How many decisions this agent triggered with this exact verdict name
35    /// (e.g. `"block"`). Zero for a verdict never folded in.
36    pub fn count(&self, verdict: &str) -> u64 {
37        self.by_verdict.get(verdict).copied().unwrap_or(0)
38    }
39}
40
41/// A swarm-wide read model: every agent's folded [`AgentActivity`], built by
42/// folding [`PolicyEvidence`] records already read off the audit topic (a
43/// replay cursor, a projection, however the caller already reads it). A pure
44/// in-process fold, not a topic reader of its own: it composes with whatever
45/// offset-resume convention the caller uses, the same way
46/// [`crate::intent::decide`] composes with whatever publishes the records it
47/// folds. The role this answers is a coordinator's: "what has the swarm been
48/// doing," read-only over evidence, never a write path of its own.
49#[derive(Debug, Clone, Default)]
50pub struct SwarmActivity {
51    by_agent: HashMap<String, AgentActivity>,
52    seen_decisions: HashSet<String>,
53}
54
55impl SwarmActivity {
56    /// An empty swarm view. Fold evidence in with [`observe`](Self::observe).
57    pub fn new() -> Self {
58        Self::default()
59    }
60
61    /// Fold one more evidence record in. A record with no `source` (a
62    /// governed action that carried no acting agent) is not attributable to
63    /// any agent and is dropped.
64    pub fn observe(&mut self, evidence: &PolicyEvidence) {
65        let Some(source) = evidence.source.as_deref() else {
66            return;
67        };
68        if !self.seen_decisions.insert(evidence.decision_id.clone()) {
69            return;
70        }
71        self.by_agent
72            .entry(source.to_owned())
73            .or_default()
74            .fold(evidence);
75    }
76
77    /// This agent's folded activity, or `None` if this view has never folded
78    /// a decision it triggered.
79    pub fn agent(&self, agent: &str) -> Option<&AgentActivity> {
80        self.by_agent.get(agent)
81    }
82
83    /// Every agent this view has folded activity for, busiest first (ties
84    /// broken by name), so a coordinator's summary reads in the order that
85    /// matters most.
86    pub fn agents(&self) -> Vec<(&str, &AgentActivity)> {
87        let mut agents: Vec<_> = self
88            .by_agent
89            .iter()
90            .map(|(name, activity)| (name.as_str(), activity))
91            .collect();
92        agents.sort_by(|a, b| b.1.decisions.cmp(&a.1.decisions).then_with(|| a.0.cmp(b.0)));
93        agents
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    fn evidence(source: &str, decision: &str, at_micros: u64) -> PolicyEvidence {
102        PolicyEvidence {
103            decision_id: format!("{source}-{decision}-{at_micros}"),
104            decision: decision.to_owned(),
105            mode: "enforce".to_owned(),
106            kind: "send".to_owned(),
107            stream: "laser".to_owned(),
108            topic: "agent.sessions".to_owned(),
109            source: Some(source.to_owned()),
110            target: None,
111            conversation: None,
112            correlation: None,
113            operation: None,
114            tool: None,
115            on_behalf_of: None,
116            reason: None,
117            approved_scope: None,
118            policy: None,
119            risk_score: None,
120            receipt_digest: String::new(),
121            previous_digest: None,
122            outcome: "effected".to_owned(),
123            at_micros,
124        }
125    }
126
127    #[test]
128    fn given_no_evidence_when_queried_then_should_have_no_agents() {
129        let swarm = SwarmActivity::new();
130        assert!(swarm.agent("planner").is_none());
131        assert!(swarm.agents().is_empty());
132    }
133
134    #[test]
135    fn given_evidence_with_no_source_when_observed_then_should_be_dropped() {
136        let mut swarm = SwarmActivity::new();
137        let mut anonymous = evidence("planner", "allow", 1);
138        anonymous.source = None;
139        swarm.observe(&anonymous);
140        assert!(swarm.agents().is_empty());
141    }
142
143    #[test]
144    fn given_several_decisions_for_one_agent_when_observed_then_should_count_by_verdict() {
145        let mut swarm = SwarmActivity::new();
146        swarm.observe(&evidence("planner", "allow", 1));
147        swarm.observe(&evidence("planner", "block", 2));
148        swarm.observe(&evidence("planner", "block", 3));
149
150        let activity = swarm.agent("planner").expect("planner has activity");
151        assert_eq!(activity.decisions, 3);
152        assert_eq!(activity.count("allow"), 1);
153        assert_eq!(activity.count("block"), 2);
154        assert_eq!(activity.count("defer"), 0);
155    }
156
157    #[test]
158    fn given_out_of_order_evidence_when_observed_then_last_decision_should_be_the_latest_by_time() {
159        let mut swarm = SwarmActivity::new();
160        swarm.observe(&evidence("planner", "allow", 100));
161        swarm.observe(&evidence("planner", "block", 50));
162
163        let activity = swarm.agent("planner").expect("planner has activity");
164        assert_eq!(
165            activity.last_decision.as_ref().map(|e| e.decision.as_str()),
166            Some("allow")
167        );
168        assert_eq!(
169            activity.last_decision.as_ref().map(|e| e.at_micros),
170            Some(100)
171        );
172    }
173
174    #[test]
175    fn given_replayed_evidence_when_observed_then_should_count_it_once() {
176        let mut swarm = SwarmActivity::new();
177        let evidence = evidence("planner", "block", 1);
178        swarm.observe(&evidence);
179        swarm.observe(&evidence);
180
181        let activity = swarm.agent("planner").expect("planner has activity");
182        assert_eq!(activity.decisions, 1);
183        assert_eq!(activity.count("block"), 1);
184    }
185
186    #[test]
187    fn given_equal_timestamps_when_observed_then_last_decision_should_use_the_id_tie_break() {
188        let mut swarm = SwarmActivity::new();
189        let mut lower = evidence("planner", "allow", 1);
190        lower.decision_id = "decision-a".to_owned();
191        let mut higher = evidence("planner", "block", 1);
192        higher.decision_id = "decision-b".to_owned();
193        swarm.observe(&higher);
194        swarm.observe(&lower);
195
196        let activity = swarm.agent("planner").expect("planner has activity");
197        assert_eq!(
198            activity
199                .last_decision
200                .as_ref()
201                .map(|evidence| evidence.decision_id.as_str()),
202            Some("decision-b")
203        );
204    }
205
206    #[test]
207    fn given_several_agents_when_listed_then_should_order_busiest_first() {
208        let mut swarm = SwarmActivity::new();
209        swarm.observe(&evidence("quiet", "allow", 1));
210        swarm.observe(&evidence("busy", "allow", 1));
211        swarm.observe(&evidence("busy", "block", 2));
212
213        let agents = swarm.agents();
214        assert_eq!(
215            agents.iter().map(|(name, _)| *name).collect::<Vec<_>>(),
216            vec!["busy", "quiet"]
217        );
218    }
219}