1use serde_json::Value as Json;
8use std::collections::HashMap;
9use tokio_util::sync::CancellationToken;
10
11pub type AgentId = u32;
17
18#[derive(Debug, Clone, PartialEq)]
20pub struct AgentRequest {
21 pub index: AgentId,
23 pub prompt: String,
24 pub label: Option<String>,
26 pub phase: String,
27 pub model: Option<String>,
30 pub effort: Option<String>,
32 pub schema: Option<Json>,
35 pub timeout_ms: Option<u64>,
36}
37
38#[derive(Debug, Clone, PartialEq)]
40pub enum AgentOutcome {
41 Done(Json),
44 Stopped,
46 Failed(String),
48}
49
50impl AgentOutcome {
51 pub(crate) fn is_journalable(&self) -> bool {
54 matches!(self, AgentOutcome::Done(_))
55 }
56}
57
58#[async_trait::async_trait]
60pub trait AgentRunner: Send + Sync + 'static {
61 async fn run_agent(&self, request: AgentRequest, cancel: CancellationToken) -> AgentOutcome;
62
63 fn tokens_used(&self, _index: AgentId) -> u64 {
66 0
67 }
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub struct Limits {
73 pub max_concurrency: usize,
75 pub max_agents: u32,
78 pub max_fanout: usize,
82 pub max_steps: u64,
84}
85
86impl Default for Limits {
87 fn default() -> Self {
88 Limits {
89 max_concurrency: default_concurrency(),
90 max_agents: 1000,
91 max_fanout: 4096,
92 max_steps: 50_000_000,
93 }
94 }
95}
96
97fn default_concurrency() -> usize {
99 let cores = std::thread::available_parallelism()
100 .map(|count| count.get())
101 .unwrap_or(4);
102 cores.clamp(2, 16)
103}
104
105impl Limits {
106 pub fn sanitized(mut self) -> Limits {
108 self.max_concurrency = self.max_concurrency.clamp(1, 16);
109 self.max_agents = self.max_agents.clamp(1, 1000);
110 self.max_fanout = self.max_fanout.clamp(1, 4096);
111 self.max_steps = self.max_steps.max(1000);
112 self
113 }
114}
115
116#[derive(Debug, Clone, Default)]
124pub struct Journal {
125 entries: HashMap<AgentId, (String, AgentOutcome)>,
126}
127
128impl Journal {
129 pub fn is_empty(&self) -> bool {
130 self.entries.is_empty()
131 }
132
133 pub fn len(&self) -> usize {
134 self.entries.len()
135 }
136
137 pub fn record_completed(&mut self, index: AgentId, prompt: &str, value: Json) {
143 self.entries
144 .insert(index, (prompt.to_string(), AgentOutcome::Done(value)));
145 }
146
147 pub(crate) fn record(&mut self, index: AgentId, prompt: &str, outcome: &AgentOutcome) {
148 if outcome.is_journalable() {
149 self.entries
150 .insert(index, (prompt.to_string(), outcome.clone()));
151 }
152 }
153
154 pub(crate) fn take_matching(&self, index: AgentId, prompt: &str) -> Option<AgentOutcome> {
156 let (recorded, outcome) = self.entries.get(&index)?;
157 (recorded == prompt).then(|| outcome.clone())
158 }
159
160 pub(crate) fn invalidate_from(&mut self, index: AgentId) {
165 self.entries.retain(|recorded, _| *recorded < index);
166 }
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172
173 #[test]
174 fn only_completed_agents_are_remembered() {
175 let mut journal = Journal::default();
176 journal.record(0, "a", &AgentOutcome::Done(Json::String("ok".into())));
177 journal.record(1, "b", &AgentOutcome::Failed("boom".into()));
178 journal.record(2, "c", &AgentOutcome::Stopped);
179 assert_eq!(journal.len(), 1);
180 assert!(journal.take_matching(1, "b").is_none());
181 }
182
183 #[test]
184 fn a_host_can_seed_a_completed_result() {
185 let mut journal = Journal::default();
186 journal.record_completed(4, "audit a.rs", Json::String("ok".into()));
187 assert_eq!(
188 journal.take_matching(4, "audit a.rs"),
189 Some(AgentOutcome::Done(Json::String("ok".into())))
190 );
191 }
192
193 #[test]
194 fn a_changed_prompt_does_not_match_its_remembered_result() {
195 let mut journal = Journal::default();
196 journal.record(
197 0,
198 "audit a.rs",
199 &AgentOutcome::Done(Json::String("ok".into())),
200 );
201 assert!(journal.take_matching(0, "audit a.rs").is_some());
202 assert!(journal.take_matching(0, "audit b.rs").is_none());
203 }
204
205 #[test]
206 fn invalidating_a_position_drops_everything_after_it() {
207 let mut journal = Journal::default();
208 for index in 0..5 {
209 journal.record(index, "p", &AgentOutcome::Done(Json::Null));
210 }
211 journal.invalidate_from(2);
212 assert_eq!(journal.len(), 2);
213 assert!(journal.take_matching(1, "p").is_some());
214 assert!(journal.take_matching(2, "p").is_none());
215 }
216
217 #[test]
218 fn limits_are_clamped_into_a_range_the_runtime_can_honor() {
219 let limits = Limits {
220 max_concurrency: 500,
221 max_agents: 100_000,
222 max_fanout: usize::MAX,
223 max_steps: 1,
224 }
225 .sanitized();
226 assert_eq!(limits.max_concurrency, 16);
227 assert_eq!(limits.max_agents, 1000);
228 assert_eq!(limits.max_fanout, 4096);
229 assert_eq!(limits.max_steps, 1000);
230 }
231
232 #[test]
233 fn the_default_concurrency_stays_within_bounds() {
234 let limits = Limits::default();
235 assert!((2..=16).contains(&limits.max_concurrency));
236 }
237}