agora_agentkit/scheduler/mod.rs
1//! Backend-agnostic batch scheduler for agent workloads.
2//!
3//! This module provides a pipeline scheduler that groups work items by model,
4//! prefix hash, and context length for optimal cache utilization across
5//! different LLM backends (Anthropic Batch API, Ollama, etc.).
6//!
7//! # Architecture
8//!
9//! The scheduler operates as a pipeline where batches are interleaved:
10//!
11//! ```text
12//! Batch 1 [agents A,B,C]: PERCEIVE → submit THINK → poll → ACT → submit REFLECT
13//! Batch 2 [agents D,E,F]: PERCEIVE → submit THINK → poll → ACT ...
14//! (D,E,F see A,B,C's committed actions in their perceptions)
15//! ```
16//!
17//! This ensures agents in later batches observe earlier batches' actions,
18//! creating a natural information flow without strict phase barriers.
19//!
20//! # Grouping
21//!
22//! Work items are grouped by priority:
23//! 1. **Model** — most expensive to switch (weight loading / pricing)
24//! 2. **Prefix hash** — KV cache reuse on both Anthropic and Ollama
25//! 3. **Context length** — avoid memory reallocation on Ollama
26//!
27//! Items waiting too long are promoted regardless of grouping optimality
28//! to prevent starvation.
29
30mod grouping;
31
32use std::time::{Duration, Instant};
33
34use crate::ids::AgentId;
35
36pub use grouping::{BatchGroup, GroupingConfig, group_work_items};
37
38// ---------------------------------------------------------------------------
39// Core types
40// ---------------------------------------------------------------------------
41
42/// Identifies what step in the agent cycle a work item represents.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
44pub enum CycleStep {
45 /// Main reasoning step — agent decides what actions to take.
46 Think,
47 /// Memory update after actions are committed.
48 Reflect,
49 /// Soul evolution check (low probability per cycle).
50 Evolve,
51 /// Deep soul mutation (very low probability per cycle).
52 Mutate,
53 /// Anonymous survey/feedback.
54 Survey,
55}
56
57impl std::fmt::Display for CycleStep {
58 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59 match self {
60 Self::Think => write!(f, "think"),
61 Self::Reflect => write!(f, "reflect"),
62 Self::Evolve => write!(f, "evolve"),
63 Self::Mutate => write!(f, "mutate"),
64 Self::Survey => write!(f, "survey"),
65 }
66 }
67}
68
69/// A unit of work: one agent's prompt for one cycle step.
70///
71/// The `P` type parameter is the prompt type — typically
72/// `misanthropic::Prompt<'static>` but kept generic for testability.
73#[derive(Debug)]
74pub struct WorkItem<P> {
75 /// Which agent this work item belongs to.
76 pub agent_id: AgentId,
77 /// The prompt to submit to the backend.
78 pub prompt: P,
79 /// Which cycle step this represents.
80 pub step: CycleStep,
81 /// Hash of the cacheable prefix (system prompt, tools, constitution).
82 /// Items with the same prefix_hash should be batched together for
83 /// cache efficiency.
84 pub prefix_hash: u64,
85 /// Model identifier (e.g. "claude-opus-4-6", "cogito:14b").
86 pub model: String,
87 /// When this item was queued. Used for starvation prevention.
88 pub queued_at: Instant,
89 /// Approximate token count for the full prompt (from token count API).
90 /// Used for context-length bucketing on Ollama.
91 pub token_count: u32,
92}
93
94/// Result of processing a single work item.
95#[derive(Debug)]
96pub struct WorkResult<R> {
97 /// Which agent this result belongs to.
98 pub agent_id: AgentId,
99 /// Which cycle step produced this result.
100 pub step: CycleStep,
101 /// The response from the backend.
102 pub response: std::result::Result<R, BatchError>,
103}
104
105/// Errors that can occur during batch processing.
106#[derive(Debug, thiserror::Error)]
107pub enum BatchError {
108 /// The backend returned an API-level error for this specific item.
109 #[error("API error: {message}")]
110 Api { message: String },
111 /// The item was canceled (e.g. batch was aborted).
112 #[error("item was canceled")]
113 Canceled,
114 /// The item expired before processing.
115 #[error("item expired")]
116 Expired,
117 /// Network or transport error.
118 #[error("transport error: {0}")]
119 Transport(String),
120 /// The backend reported an unexpected error.
121 #[error("{0}")]
122 Other(#[from] anyhow::Error),
123}
124
125// ---------------------------------------------------------------------------
126// Backend trait
127// ---------------------------------------------------------------------------
128
129/// A handle to a submitted batch, returned by [`BatchBackend::submit`].
130///
131/// The handle is opaque to the scheduler — backends define their own state.
132pub trait PendingHandle: Send + 'static {}
133
134/// Blanket impl: anything Send + 'static can be a PendingHandle.
135impl<T: Send + 'static> PendingHandle for T {}
136
137/// The state of a polled batch.
138pub enum BatchState<R, H: PendingHandle> {
139 /// Still processing. The handle should be polled again later.
140 Pending(H),
141 /// All results are available.
142 Ready(Vec<WorkResult<R>>),
143}
144
145/// Backend-agnostic interface for submitting and polling batch work.
146///
147/// Implementations wrap specific LLM backends (Anthropic Batch API, Ollama,
148/// etc.) and handle prompt submission, polling, and result collection.
149///
150/// The type parameters:
151/// - `P` — the prompt type (e.g. `misanthropic::Prompt<'static>`)
152/// - `R` — the response type (e.g. `misanthropic::prompt::Message<'static>`)
153#[allow(async_fn_in_trait)]
154pub trait BatchBackend<P, R>: Send + Sync {
155 /// The handle type returned by `submit`, used for polling.
156 type Handle: PendingHandle;
157
158 /// Submit a batch of work items. Returns a handle for polling.
159 async fn submit(
160 &self,
161 items: Vec<WorkItem<P>>,
162 ) -> anyhow::Result<Self::Handle>;
163
164 /// Poll a pending batch. Returns [`BatchState::Pending`] if still
165 /// processing, or [`BatchState::Ready`] with all results.
166 async fn poll(
167 &self,
168 handle: Self::Handle,
169 ) -> anyhow::Result<BatchState<R, Self::Handle>>;
170
171 /// Count tokens for a prompt. Used for grouping decisions and
172 /// cache eligibility checks.
173 ///
174 /// Returns `None` if the backend doesn't support token counting,
175 /// in which case the scheduler will skip token-based grouping.
176 async fn count_tokens(&self, prompt: &P) -> anyhow::Result<Option<u32>>;
177
178 /// Human-readable backend name for logging.
179 fn backend_name(&self) -> &str;
180}
181
182// ---------------------------------------------------------------------------
183// Scheduler
184// ---------------------------------------------------------------------------
185
186/// Configuration for the pipeline scheduler.
187#[derive(Debug, Clone)]
188pub struct SchedulerConfig {
189 /// Maximum number of work items per batch submission.
190 pub batch_size: usize,
191 /// Maximum time a work item can wait before being force-scheduled.
192 pub max_wait: Duration,
193 /// Poll interval when waiting for batch results.
194 pub poll_interval: Duration,
195 /// Context length bucket size for Ollama grouping (in tokens).
196 /// Items within the same bucket are considered similar enough.
197 pub context_length_bucket: u32,
198}
199
200impl Default for SchedulerConfig {
201 fn default() -> Self {
202 Self {
203 batch_size: 10,
204 max_wait: Duration::from_secs(120),
205 poll_interval: Duration::from_secs(5),
206 context_length_bucket: 4096,
207 }
208 }
209}
210
211/// Pipeline scheduler that manages batch submission and interleaving.
212///
213/// The scheduler doesn't own backends directly — instead, callers use
214/// [`Scheduler::next_batch`] to get the next group of items to submit,
215/// then handle submission/polling themselves. This keeps the scheduler
216/// backend-agnostic and testable.
217pub struct Scheduler<P> {
218 config: SchedulerConfig,
219 /// Pending work items waiting to be batched.
220 queue: Vec<WorkItem<P>>,
221}
222
223impl<P> Scheduler<P> {
224 /// Create a new scheduler with the given configuration.
225 pub fn new(config: SchedulerConfig) -> Self {
226 Self {
227 config,
228 queue: Vec::new(),
229 }
230 }
231
232 /// Add work items to the scheduler's queue.
233 pub fn enqueue(&mut self, items: impl IntoIterator<Item = WorkItem<P>>) {
234 self.queue.extend(items);
235 }
236
237 /// Number of items currently in the queue.
238 pub fn pending_count(&self) -> usize {
239 self.queue.len()
240 }
241
242 /// Returns `true` if the queue is empty.
243 pub fn is_empty(&self) -> bool {
244 self.queue.is_empty()
245 }
246
247 /// Take the next batch of work items from the queue, grouped optimally.
248 ///
249 /// Returns `None` if the queue is empty. Otherwise returns a vec of
250 /// [`BatchGroup`]s, each containing items that should be submitted
251 /// together for optimal cache utilization.
252 ///
253 /// Items past `max_wait` are promoted into this batch regardless of
254 /// grouping optimality.
255 pub fn next_batch(&mut self) -> Option<Vec<BatchGroup<P>>> {
256 if self.queue.is_empty() {
257 return None;
258 }
259
260 let now = Instant::now();
261 let batch_size = self.config.batch_size;
262 let max_wait = self.config.max_wait;
263 let bucket_size = self.config.context_length_bucket;
264
265 // Partition: starving items first, then optimal grouping for the rest
266 let mut starving = Vec::new();
267 let mut normal = Vec::new();
268
269 // Drain the queue, splitting into starving vs normal
270 for item in self.queue.drain(..) {
271 if now.duration_since(item.queued_at) >= max_wait {
272 starving.push(item);
273 } else {
274 normal.push(item);
275 }
276 }
277
278 // How many slots remain after accommodating starving items
279 let remaining_capacity = batch_size.saturating_sub(starving.len());
280
281 // Group normal items and take up to remaining_capacity
282 let mut selected = starving;
283 let mut returned = Vec::new();
284
285 if remaining_capacity > 0 && !normal.is_empty() {
286 // Sort for optimal grouping: model, prefix_hash, token_count
287 normal.sort_by(|a, b| {
288 a.model
289 .cmp(&b.model)
290 .then_with(|| a.prefix_hash.cmp(&b.prefix_hash))
291 .then_with(|| {
292 let bucket_a = a.token_count / bucket_size;
293 let bucket_b = b.token_count / bucket_size;
294 bucket_a.cmp(&bucket_b)
295 })
296 });
297
298 // Take the first contiguous group up to remaining_capacity
299 let take = remaining_capacity.min(normal.len());
300 selected.extend(normal.drain(..take));
301 returned = normal;
302 }
303
304 // Put un-selected items back in the queue
305 self.queue = returned;
306
307 if selected.is_empty() {
308 return None;
309 }
310
311 // Group the selected items
312 let grouping_config = GroupingConfig {
313 context_length_bucket: bucket_size,
314 };
315 Some(group_work_items(selected, &grouping_config))
316 }
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322
323 fn make_item(
324 agent_num: u32,
325 model: &str,
326 prefix_hash: u64,
327 token_count: u32,
328 ) -> WorkItem<String> {
329 WorkItem {
330 agent_id: AgentId::new(),
331 prompt: format!("prompt_{agent_num}"),
332 step: CycleStep::Think,
333 prefix_hash,
334 model: model.to_string(),
335 queued_at: Instant::now(),
336 token_count,
337 }
338 }
339
340 fn make_stale_item(
341 agent_num: u32,
342 model: &str,
343 prefix_hash: u64,
344 ) -> WorkItem<String> {
345 WorkItem {
346 agent_id: AgentId::new(),
347 prompt: format!("prompt_{agent_num}"),
348 step: CycleStep::Think,
349 prefix_hash,
350 model: model.to_string(),
351 // Queued 5 minutes ago — should be starving
352 queued_at: Instant::now() - Duration::from_secs(300),
353 token_count: 4096,
354 }
355 }
356
357 #[test]
358 fn empty_queue_returns_none() {
359 let mut sched = Scheduler::<String>::new(SchedulerConfig::default());
360 assert!(sched.next_batch().is_none());
361 }
362
363 #[test]
364 fn basic_grouping() {
365 let mut sched = Scheduler::new(SchedulerConfig {
366 batch_size: 10,
367 ..Default::default()
368 });
369
370 sched.enqueue(vec![
371 make_item(1, "claude-opus-4-6", 100, 5000),
372 make_item(2, "claude-opus-4-6", 100, 5000),
373 make_item(3, "cogito:14b", 200, 8000),
374 ]);
375
376 let groups = sched.next_batch().unwrap();
377
378 // Should produce 2 groups: one for claude, one for cogito
379 assert_eq!(groups.len(), 2);
380 assert!(sched.is_empty());
381 }
382
383 #[test]
384 fn starvation_prevention() {
385 let mut sched = Scheduler::new(SchedulerConfig {
386 batch_size: 2,
387 max_wait: Duration::from_secs(60),
388 ..Default::default()
389 });
390
391 // Add a stale item with an unusual model
392 sched.enqueue(vec![make_stale_item(1, "rare-model", 999)]);
393 // Add fresh items with a common model
394 sched.enqueue(vec![
395 make_item(2, "claude-opus-4-6", 100, 5000),
396 make_item(3, "claude-opus-4-6", 100, 5000),
397 ]);
398
399 let groups = sched.next_batch().unwrap();
400
401 // The stale item should be included despite being a different model
402 let total_items: usize = groups.iter().map(|g| g.items.len()).sum();
403 assert_eq!(total_items, 2); // batch_size = 2
404
405 // The stale "rare-model" item must be in one of the groups
406 let has_rare = groups.iter().any(|g| g.model == "rare-model");
407 assert!(has_rare, "stale item should be force-scheduled");
408
409 // One item should remain in queue
410 assert_eq!(sched.pending_count(), 1);
411 }
412
413 #[test]
414 fn batch_size_limit() {
415 let mut sched = Scheduler::new(SchedulerConfig {
416 batch_size: 2,
417 ..Default::default()
418 });
419
420 sched.enqueue(vec![
421 make_item(1, "claude-opus-4-6", 100, 5000),
422 make_item(2, "claude-opus-4-6", 100, 5000),
423 make_item(3, "claude-opus-4-6", 100, 5000),
424 ]);
425
426 let groups = sched.next_batch().unwrap();
427 let total: usize = groups.iter().map(|g| g.items.len()).sum();
428 assert_eq!(total, 2);
429 assert_eq!(sched.pending_count(), 1);
430 }
431}