Skip to main content

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}