1use crate::govern::PolicyEvidence;
2use std::collections::{HashMap, HashSet};
3
4#[derive(Debug, Clone, Default, PartialEq)]
10pub struct AgentActivity {
11 pub decisions: u64,
13 by_verdict: HashMap<String, u64>,
14 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 pub fn count(&self, verdict: &str) -> u64 {
37 self.by_verdict.get(verdict).copied().unwrap_or(0)
38 }
39}
40
41#[derive(Debug, Clone, Default)]
50pub struct SwarmActivity {
51 by_agent: HashMap<String, AgentActivity>,
52 seen_decisions: HashSet<String>,
53}
54
55impl SwarmActivity {
56 pub fn new() -> Self {
58 Self::default()
59 }
60
61 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 pub fn agent(&self, agent: &str) -> Option<&AgentActivity> {
80 self.by_agent.get(agent)
81 }
82
83 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}