orch/engine/router/weights.rs
1//! Per-agent routing weights and rate-limit recovery.
2//!
3//! `RateLimitState` tracks a single agent's current routing weight and
4//! recovers it over time after rate-limit events.
5//!
6//! `AgentWeights` aggregates per-agent states and provides weighted
7//! probabilistic selection via `weighted_select`.
8
9use std::collections::HashMap;
10use std::time::{Duration, Instant};
11
12use super::selection::{simple_hash_fraction_for, simple_hash_index_for};
13
14/// Default weight for agents with full capacity.
15pub(super) const DEFAULT_WEIGHT: f64 = 1.0;
16
17/// Minimum weight — an agent never drops below this (still gets occasional tasks).
18pub(super) const MIN_WEIGHT: f64 = 0.05;
19
20/// How much to reduce weight on each rate limit hit (multiplicative decay).
21pub(super) const RATE_LIMIT_DECAY: f64 = 0.3;
22
23/// Weight to use for an agent that is absent from a configured `router.weights`
24/// map. Rather than the constant `DEFAULT_WEIGHT` (1.0) — which would silently
25/// outrank every agent the operator *did* configure — an omitted agent falls
26/// back to the minimum of the explicitly configured weights. An empty map
27/// means no weights are configured at all, so `DEFAULT_WEIGHT` applies to
28/// everyone equally.
29pub(super) fn default_missing_weight(configured_weights: &HashMap<String, f64>) -> f64 {
30 configured_weights
31 .values()
32 .copied()
33 .fold(None, |min, w| Some(min.map_or(w, |m: f64| m.min(w))))
34 .unwrap_or(DEFAULT_WEIGHT)
35}
36
37/// Generate jitter duration for recovery delay based on agent name.
38/// Uses a simple hash to create deterministic but varied jitter per agent.
39fn generate_recovery_jitter(agent: &str) -> Duration {
40 // Simple hash: sum of char values modulo a large prime
41 let hash: u64 = agent.bytes().fold(0u64, |acc, b| {
42 acc.wrapping_mul(31).wrapping_add(u64::from(b))
43 });
44 // Map hash to 0..RECOVERY_JITTER_MAX range
45 let max_secs = RECOVERY_JITTER_MAX.as_secs();
46 let jitter_secs = hash % max_secs;
47 Duration::from_secs(jitter_secs)
48}
49
50/// Duration after which a rate-limited agent starts recovering weight.
51pub(super) const RECOVERY_DELAY: Duration = Duration::from_secs(60);
52
53/// Maximum jitter added to recovery delay to stagger agent recoveries.
54/// This prevents thundering herd where all agents recover simultaneously.
55pub(super) const RECOVERY_JITTER_MAX: Duration = Duration::from_secs(30);
56
57/// Per-tick weight recovery amount (additive, applied each routing call).
58pub(super) const RECOVERY_RATE: f64 = 0.1;
59
60/// Rate limit state for a single agent.
61#[derive(Debug, Clone)]
62pub struct RateLimitState {
63 /// Current routing weight (0.0..=1.0). Higher = more tasks.
64 pub weight: f64,
65 /// When the last rate limit error was recorded.
66 pub last_limited_at: Option<Instant>,
67 /// Jitter added to recovery delay to stagger recoveries (avoid thundering herd).
68 pub recovery_jitter: Duration,
69 /// How many consecutive rate limit hits.
70 pub consecutive_hits: u32,
71 /// The configured base weight this agent should recover toward.
72 pub base_weight: f64,
73}
74
75impl Default for RateLimitState {
76 fn default() -> Self {
77 Self {
78 weight: DEFAULT_WEIGHT,
79 last_limited_at: None,
80 recovery_jitter: Duration::ZERO,
81 consecutive_hits: 0,
82 base_weight: DEFAULT_WEIGHT,
83 }
84 }
85}
86
87impl RateLimitState {
88 /// Record a rate limit event — decay the weight.
89 /// Note: Jitter is set at the AgentWeights level using the agent name.
90 pub fn record_rate_limit(&mut self) {
91 self.consecutive_hits += 1;
92 self.weight = (self.weight * RATE_LIMIT_DECAY).max(MIN_WEIGHT);
93 self.last_limited_at = Some(Instant::now());
94 }
95
96 /// Record a successful completion — bump weight back toward base_weight.
97 pub fn record_success(&mut self) {
98 self.consecutive_hits = 0;
99 self.weight = (self.weight + RECOVERY_RATE).min(self.base_weight);
100 }
101
102 /// Tick recovery: if enough time has passed since the last limit (plus jitter), gradually restore.
103 pub fn maybe_recover(&mut self) {
104 if let Some(last) = self.last_limited_at {
105 let recovery_threshold = RECOVERY_DELAY + self.recovery_jitter;
106 if last.elapsed() >= recovery_threshold {
107 self.weight = (self.weight + RECOVERY_RATE).min(self.base_weight);
108 if self.weight >= self.base_weight {
109 self.last_limited_at = None;
110 self.recovery_jitter = Duration::ZERO;
111 self.consecutive_hits = 0;
112 }
113 }
114 }
115 }
116
117 /// Is this agent currently rate-limited (weight below its base weight)?
118 pub fn is_limited(&self) -> bool {
119 self.weight < self.base_weight
120 }
121}
122
123/// Tracks per-agent weights for weighted round-robin routing.
124#[derive(Debug, Clone, Default)]
125pub struct AgentWeights {
126 pub states: HashMap<String, RateLimitState>,
127 /// Configured base weights per agent (from config.yml).
128 /// Used as the recovery ceiling instead of DEFAULT_WEIGHT.
129 pub base_weights: HashMap<String, f64>,
130}
131
132impl AgentWeights {
133 /// Ensure all available agents have an entry, using configured base weights.
134 pub fn ensure_agents(&mut self, agents: &[String]) {
135 for agent in agents {
136 self.states.entry(agent.clone()).or_default();
137 }
138 }
139
140 /// Ensure all available agents have an entry, applying configured base weights.
141 ///
142 /// Agents with an explicit weight in `config.yml` use that as their base.
143 /// Agents without a configured weight default to the minimum of the
144 /// explicitly configured weights (see `default_missing_weight`), so an
145 /// omission never outranks an agent the operator actually configured.
146 pub fn ensure_agents_with_weights(
147 &mut self,
148 agents: &[String],
149 configured_weights: &std::collections::HashMap<String, f64>,
150 ) {
151 for agent in agents {
152 let base = configured_weights
153 .get(agent)
154 .copied()
155 .unwrap_or_else(|| default_missing_weight(configured_weights));
156 let state = self
157 .states
158 .entry(agent.clone())
159 .or_insert_with(|| RateLimitState {
160 weight: base,
161 base_weight: base,
162 ..Default::default()
163 });
164 state.base_weight = base;
165 // Update base weight if agent was already initialized (e.g. after config reload)
166 // but only if the agent isn't currently rate-limited (don't override decay)
167 if !state.is_limited() {
168 state.weight = base;
169 }
170 }
171 }
172
173 /// Record a rate limit event for an agent.
174 pub fn record_rate_limit(&mut self, agent: &str) {
175 let state = self.states.entry(agent.to_string()).or_default();
176 state.record_rate_limit();
177 // Generate jitter to stagger recovery times across agents
178 state.recovery_jitter = generate_recovery_jitter(agent);
179 tracing::info!(
180 agent,
181 weight = state.weight,
182 hits = state.consecutive_hits,
183 jitter_secs = state.recovery_jitter.as_secs(),
184 "agent weight reduced (rate limit)"
185 );
186 }
187
188 /// Record a successful task completion for an agent.
189 pub fn record_success(&mut self, agent: &str) {
190 self.states
191 .entry(agent.to_string())
192 .or_default()
193 .record_success();
194 }
195
196 /// Tick recovery for all agents.
197 pub fn tick_recovery(&mut self) {
198 for (agent, state) in &mut self.states {
199 let was_limited = state.is_limited();
200 state.maybe_recover();
201 if was_limited && !state.is_limited() {
202 tracing::info!(agent, "agent weight fully recovered");
203 }
204 }
205 }
206
207 /// Select an agent by weighted probability from the given list.
208 ///
209 /// Uses a simple weighted random selection: each agent's probability is
210 /// proportional to its weight. If all weights are zero (shouldn't happen
211 /// due to MIN_WEIGHT), falls back to uniform selection.
212 pub fn weighted_select(&self, agents: &[String], task_id: &str) -> Option<String> {
213 if agents.is_empty() {
214 return None;
215 }
216
217 let weights: Vec<f64> = agents
218 .iter()
219 .map(|a| {
220 self.states.get(a).map_or_else(
221 || {
222 self.base_weights
223 .get(a)
224 .copied()
225 .unwrap_or_else(|| default_missing_weight(&self.base_weights))
226 },
227 |s| s.weight,
228 )
229 })
230 .collect();
231
232 let total: f64 = weights.iter().sum();
233 if total <= 0.0 {
234 // Safety fallback: uniform random
235 let idx = simple_hash_index_for(agents.len(), task_id);
236 return Some(agents[idx].clone());
237 }
238
239 // Deterministic-ish selection using a hash of the current time
240 // to avoid requiring rand crate. Good enough for load distribution.
241 let pick = simple_hash_fraction_for(task_id) * total;
242 let mut cumulative = 0.0;
243 for (i, w) in weights.iter().enumerate() {
244 cumulative += w;
245 if pick < cumulative {
246 return Some(agents[i].clone());
247 }
248 }
249
250 // Rounding edge case — return last agent (agents is guaranteed non-empty by caller)
251 debug_assert!(!agents.is_empty(), "agents must not be empty");
252 Some(agents[agents.len() - 1].clone())
253 }
254
255 /// Get the current weight for an agent.
256 pub fn get_weight(&self, agent: &str) -> f64 {
257 self.states.get(agent).map_or_else(
258 || {
259 self.base_weights
260 .get(agent)
261 .copied()
262 .unwrap_or_else(|| default_missing_weight(&self.base_weights))
263 },
264 |s| s.weight,
265 )
266 }
267
268 /// Get a snapshot of all agent weights (for logging/debugging).
269 pub fn snapshot(&self) -> Vec<(String, f64, u32)> {
270 let mut snap: Vec<_> = self
271 .states
272 .iter()
273 .map(|(a, s)| (a.clone(), s.weight, s.consecutive_hits))
274 .collect();
275 snap.sort_by(|a, b| a.0.cmp(&b.0));
276 snap
277 }
278}