1use crate::agent::{AgentRegistry, RegisteredCard};
2use crate::error::LaserError;
3use crate::provenance::{AgentTopic, Provenance};
4use crate::types::{AgentId, PrincipalId};
5use laser_wire::agent::CapabilityDescriptor;
6
7#[derive(Debug, Clone)]
11pub enum Router {
12 To(AgentId),
13 ToPrincipal {
16 agent: AgentId,
17 principal: PrincipalId,
18 },
19 Broadcast,
20 ToCapable(CapabilitySelector),
22 AllCapable(CapabilitySelector),
24}
25
26#[derive(Debug, Clone)]
29pub struct CapabilitySelector {
30 pub skill: String,
31 pub policy: RoutePolicy,
32 pub principal: Option<PrincipalId>,
35}
36
37impl CapabilitySelector {
38 pub fn new(skill: impl Into<String>, policy: RoutePolicy) -> Self {
40 Self {
41 skill: skill.into(),
42 policy,
43 principal: None,
44 }
45 }
46
47 pub fn principal(mut self, principal: PrincipalId) -> Self {
49 self.principal = Some(principal);
50 self
51 }
52}
53
54#[derive(Debug, Clone, Default)]
72pub enum InboxRoute {
73 #[default]
78 Advertised,
79 Fixed(AgentTopic<'static>),
84}
85
86impl InboxRoute {
87 pub fn resolve(
96 &self,
97 agent: &AgentId,
98 advertised: Option<&str>,
99 ) -> Result<iggy::prelude::Identifier, LaserError> {
100 let topic = match self {
101 Self::Fixed(topic) => topic.topic_string(),
102 Self::Advertised => advertised
103 .ok_or_else(|| LaserError::NoInbox {
104 agent: agent.as_str().to_owned(),
105 })?
106 .to_owned(),
107 };
108 iggy::prelude::Identifier::named(&topic).map_err(|error| {
109 LaserError::HandlerConfig(format!("inbox topic `{topic}` is invalid: {error}"))
110 })
111 }
112}
113
114#[derive(Clone, Default)]
116pub enum RoutePolicy {
117 Cheapest,
119 Fastest,
121 LeastLoaded,
123 Sticky(AgentId),
126 #[default]
128 Any,
129 Custom(std::sync::Arc<dyn RouteScorer>),
132}
133
134impl std::fmt::Debug for RoutePolicy {
137 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138 match self {
139 Self::Cheapest => write!(f, "Cheapest"),
140 Self::Fastest => write!(f, "Fastest"),
141 Self::LeastLoaded => write!(f, "LeastLoaded"),
142 Self::Sticky(agent) => f.debug_tuple("Sticky").field(agent).finish(),
143 Self::Any => write!(f, "Any"),
144 Self::Custom(_) => write!(f, "Custom(..)"),
145 }
146 }
147}
148
149pub trait RouteScorer: Send + Sync {
155 fn select(&self, skill_id: &str, candidates: &[RouteCandidate<'_>]) -> Option<usize>;
157}
158
159pub struct RouteCandidate<'a> {
162 pub agent: &'a AgentId,
164 pub card: &'a RegisteredCard,
166 pub capability: Option<CapabilityDescriptor>,
170}
171
172fn select(skill_id: &str, candidates: &[&RegisteredCard], policy: &RoutePolicy) -> Option<AgentId> {
178 let view: Vec<RouteCandidate<'_>> = candidates
181 .iter()
182 .map(|card| RouteCandidate {
183 agent: &card.agent,
184 card,
185 capability: card
186 .card
187 .capabilities
188 .iter()
189 .find(|capability| capability.skill_id == skill_id)
190 .cloned(),
191 })
192 .collect();
193 let chosen = match policy {
197 RoutePolicy::Any => view.first(),
198 RoutePolicy::Sticky(agent) => view
199 .iter()
200 .find(|candidate| candidate.agent == agent)
201 .or_else(|| view.first()),
202 RoutePolicy::Cheapest => view.iter().min_by_key(|candidate| {
203 candidate
204 .capability
205 .as_ref()
206 .and_then(|d| d.cost_class)
207 .unwrap_or(u8::MAX)
208 }),
209 RoutePolicy::Fastest => view.iter().min_by_key(|candidate| {
210 candidate
211 .capability
212 .as_ref()
213 .and_then(|d| d.latency_class)
214 .unwrap_or(u8::MAX)
215 }),
216 RoutePolicy::LeastLoaded => view.iter().min_by_key(|candidate| {
217 candidate
218 .capability
219 .as_ref()
220 .and_then(|d| d.load)
221 .unwrap_or(u16::MAX)
222 }),
223 RoutePolicy::Custom(scorer) => scorer.select(skill_id, &view).and_then(|i| view.get(i)),
224 };
225 chosen.map(|candidate| candidate.agent.clone())
226}
227
228impl Router {
229 pub fn to(agent: AgentId) -> Self {
231 Self::To(agent)
232 }
233
234 pub fn to_principal(agent: AgentId, principal: PrincipalId) -> Self {
236 Self::ToPrincipal { agent, principal }
237 }
238
239 pub fn broadcast() -> Self {
241 Self::Broadcast
242 }
243
244 pub fn to_capable(skill: impl Into<String>, policy: RoutePolicy) -> Self {
246 Self::ToCapable(CapabilitySelector::new(skill, policy))
247 }
248
249 pub fn all_capable(skill: impl Into<String>, policy: RoutePolicy) -> Self {
251 Self::AllCapable(CapabilitySelector::new(skill, policy))
252 }
253
254 pub fn apply(&self, provenance: &mut Provenance) {
259 match self {
260 Self::To(agent) | Self::ToPrincipal { agent, .. } => {
261 provenance.target_agent_id = Some(agent.clone());
262 }
263 Self::Broadcast | Self::ToCapable(_) | Self::AllCapable(_) => {
264 provenance.target_agent_id = None;
265 }
266 }
267 }
268
269 pub fn resolve_targets(
275 &self,
276 registry: &AgentRegistry,
277 now_micros: u64,
278 ) -> Result<Vec<AgentId>, LaserError> {
279 match self {
280 Self::To(agent) => Ok(vec![agent.clone()]),
281 Self::ToPrincipal { agent, principal } => {
282 require_principal(registry, agent, *principal)?;
283 Ok(vec![agent.clone()])
284 }
285 Self::Broadcast => Ok(Vec::new()),
286 Self::ToCapable(selector) => {
287 let candidates = trusted_candidates(registry, selector, now_micros)?;
288 select(&selector.skill, &candidates, &selector.policy)
289 .map(|agent| vec![agent])
290 .ok_or_else(|| LaserError::NoCapableAgent {
291 skill: selector.skill.clone(),
292 })
293 }
294 Self::AllCapable(selector) => {
295 let agents: Vec<AgentId> = trusted_candidates(registry, selector, now_micros)?
296 .iter()
297 .map(|card| card.agent.clone())
298 .collect();
299 if agents.is_empty() {
300 Err(LaserError::NoCapableAgent {
301 skill: selector.skill.clone(),
302 })
303 } else {
304 Ok(agents)
305 }
306 }
307 }
308 }
309
310 #[cfg(feature = "query")]
312 pub(crate) fn requires_presence(&self) -> bool {
313 matches!(self, Self::ToPrincipal { .. })
314 || matches!(self, Self::ToCapable(selector) | Self::AllCapable(selector) if selector.principal.is_some())
315 }
316
317 #[cfg(any(feature = "sign", test))]
320 pub(crate) fn required_principal(&self) -> Option<PrincipalId> {
321 match self {
322 Self::ToPrincipal { principal, .. } => Some(*principal),
323 Self::ToCapable(selector) | Self::AllCapable(selector) => selector.principal,
324 Self::To(_) | Self::Broadcast => None,
325 }
326 }
327}
328
329fn trusted_candidates<'a>(
330 registry: &'a AgentRegistry<'_>,
331 selector: &CapabilitySelector,
332 now_micros: u64,
333) -> Result<Vec<&'a RegisteredCard>, LaserError> {
334 let candidates = registry.resolve(&selector.skill, now_micros);
335 let Some(principal) = selector.principal else {
336 return Ok(candidates);
337 };
338 let trusted = filter_candidates_by_principal(&candidates, principal, |agent| {
339 registry.principal_for(agent)
340 });
341 if trusted.is_empty()
342 && let Some(card) = candidates.first()
343 {
344 return Err(principal_mismatch(registry, &card.agent, principal));
345 }
346 Ok(trusted)
347}
348
349fn filter_candidates_by_principal<'a>(
350 candidates: &[&'a RegisteredCard],
351 required: PrincipalId,
352 principal_for: impl Fn(&AgentId) -> Option<PrincipalId>,
353) -> Vec<&'a RegisteredCard> {
354 candidates
355 .iter()
356 .copied()
357 .filter(|card| principal_for(&card.agent) == Some(required))
358 .collect()
359}
360
361fn require_principal(
362 registry: &AgentRegistry<'_>,
363 agent: &AgentId,
364 principal: PrincipalId,
365) -> Result<(), LaserError> {
366 if registry.principal_for(agent) == Some(principal) {
367 Ok(())
368 } else {
369 Err(principal_mismatch(registry, agent, principal))
370 }
371}
372
373fn principal_mismatch(
374 registry: &AgentRegistry<'_>,
375 agent: &AgentId,
376 expected: PrincipalId,
377) -> LaserError {
378 LaserError::RoutePrincipalMismatch {
379 agent: agent.to_string(),
380 expected: expected.get(),
381 actual: registry.principal_for(agent).map(PrincipalId::get),
382 }
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388 use crate::types::ConversationId;
389
390 #[test]
391 fn given_a_route_to_an_agent_when_applied_then_should_set_the_target() {
392 let mut provenance = Provenance::builder()
393 .conversation_id(ConversationId::new())
394 .build();
395 Router::to("executor".parse().expect("executor is a valid agent id"))
396 .apply(&mut provenance);
397 assert_eq!(
398 provenance
399 .target_agent_id
400 .expect("the target should be set")
401 .as_str(),
402 "executor"
403 );
404 }
405
406 #[test]
407 fn given_a_broadcast_route_when_applied_then_should_clear_the_target() {
408 let mut provenance = Provenance::builder()
409 .conversation_id(ConversationId::new())
410 .target_agent_id("executor".parse().expect("executor is a valid agent id"))
411 .build();
412 Router::broadcast().apply(&mut provenance);
413 assert!(provenance.target_agent_id.is_none());
414 }
415
416 fn card(
417 agent: &str,
418 cost: Option<u8>,
419 latency: Option<u8>,
420 load: Option<u16>,
421 ) -> RegisteredCard {
422 use laser_wire::agent::{AgentCard, CapabilityDescriptor};
423 RegisteredCard {
424 agent: agent.parse().expect("valid agent id"),
425 card: AgentCard {
426 name: None,
427 version: None,
428 capabilities: vec![CapabilityDescriptor {
429 skill_id: "diagnose".to_owned(),
430 input: None,
431 output: None,
432 cost_class: cost,
433 latency_class: latency,
434 max_concurrency: None,
435 health: None,
436 load,
437 }],
438 ttl_micros: None,
439 },
440 observed_at_micros: 0,
441 }
442 }
443
444 #[test]
445 fn given_candidates_when_selected_by_policy_then_should_pick_the_best_by_that_class() {
446 let cheap = card("cheap", Some(1), Some(9), Some(900));
447 let fast = card("fast", Some(9), Some(1), Some(500));
448 let idle = card("idle", Some(5), Some(5), Some(10));
449 let candidates = [&cheap, &fast, &idle];
450
451 let pick = |policy| select("diagnose", &candidates, &policy).map(|a| a.as_str().to_owned());
452 assert_eq!(pick(RoutePolicy::Cheapest).as_deref(), Some("cheap"));
453 assert_eq!(pick(RoutePolicy::Fastest).as_deref(), Some("fast"));
454 assert_eq!(pick(RoutePolicy::LeastLoaded).as_deref(), Some("idle"));
455 assert_eq!(
456 pick(RoutePolicy::Sticky("fast".parse().expect("valid agent id"))).as_deref(),
457 Some("fast")
458 );
459 assert!(pick(RoutePolicy::Sticky("gone".parse().expect("valid agent id"))).is_some());
461 }
462
463 #[test]
464 fn given_a_principal_bound_selector_when_filtering_then_should_drop_foreign_claims() {
465 let trusted = card("trusted", Some(1), None, None);
466 let foreign = card("foreign", Some(2), None, None);
467 let candidates = [&trusted, &foreign];
468
469 let filtered = filter_candidates_by_principal(&candidates, PrincipalId::new(7), |agent| {
470 match agent.as_str() {
471 "trusted" => Some(PrincipalId::new(7)),
472 "foreign" => Some(PrincipalId::new(9)),
473 _ => None,
474 }
475 });
476
477 assert_eq!(filtered.len(), 1);
478 assert_eq!(filtered[0].agent.as_str(), "trusted");
479 }
480
481 #[test]
482 fn given_a_principal_bound_route_when_reading_required_identity_then_should_return_principal() {
483 let route = Router::to_principal(
484 "metrics".parse().expect("metrics is a valid agent id"),
485 PrincipalId::new(42),
486 );
487
488 assert_eq!(route.required_principal(), Some(PrincipalId::new(42)));
489 }
490
491 struct HighestLoad;
492
493 impl RouteScorer for HighestLoad {
494 fn select(&self, _skill_id: &str, candidates: &[RouteCandidate<'_>]) -> Option<usize> {
495 (0..candidates.len()).max_by_key(|&i| {
496 candidates[i]
497 .capability
498 .as_ref()
499 .and_then(|d| d.load)
500 .unwrap_or(0)
501 })
502 }
503 }
504
505 struct RefuseAll;
506
507 impl RouteScorer for RefuseAll {
508 fn select(&self, _skill_id: &str, _candidates: &[RouteCandidate<'_>]) -> Option<usize> {
509 None
510 }
511 }
512
513 #[test]
514 fn given_a_custom_scorer_when_selected_then_should_rank_over_the_same_candidate_view() {
515 let cheap = card("cheap", Some(1), Some(9), Some(900));
516 let idle = card("idle", Some(5), Some(5), Some(10));
517 let candidates = [&cheap, &idle];
518
519 let busiest = select(
520 "diagnose",
521 &candidates,
522 &RoutePolicy::Custom(std::sync::Arc::new(HighestLoad)),
523 );
524 assert_eq!(
525 busiest.map(|a| a.as_str().to_owned()).as_deref(),
526 Some("cheap")
527 );
528
529 let refused = select(
532 "diagnose",
533 &candidates,
534 &RoutePolicy::Custom(std::sync::Arc::new(RefuseAll)),
535 );
536 assert!(refused.is_none());
537 assert!(select("diagnose", &[], &RoutePolicy::Any).is_none());
539 }
540
541 #[test]
542 fn given_a_fixed_inbox_route_when_resolved_then_should_use_that_topic_ignoring_presence() {
543 let agent: AgentId = "metrics".parse().expect("valid agent id");
544 let route = InboxRoute::Fixed(AgentTopic::Sessions);
545 let id = route
547 .resolve(&agent, Some("some.other.topic"))
548 .expect("a fixed route resolves");
549 assert_eq!(id.to_string(), "agent.sessions");
550 }
551
552 #[test]
553 fn given_an_advertised_route_when_an_inbox_is_present_then_should_resolve_to_it() {
554 let agent: AgentId = "metrics".parse().expect("valid agent id");
555 let id = InboxRoute::Advertised
556 .resolve(&agent, Some("metrics.work.2026"))
557 .expect("an advertised inbox resolves");
558 assert_eq!(id.to_string(), "metrics.work.2026");
559 }
560
561 #[test]
562 fn given_an_advertised_route_when_no_inbox_then_should_error_without_a_fallback() {
563 let agent: AgentId = "metrics".parse().expect("valid agent id");
564 let error = InboxRoute::Advertised
565 .resolve(&agent, None)
566 .expect_err("no inbox fails");
567 assert!(
568 matches!(error, LaserError::NoInbox { agent } if agent == "metrics"),
569 "advertised route with no inbox must fail loud, never fall back to a shared topic",
570 );
571 }
572}