1use std::collections::{HashMap, HashSet, hash_map::DefaultHasher};
21use std::hash::{Hash, Hasher};
22use std::sync::atomic::{AtomicBool, Ordering};
23
24use async_trait::async_trait;
25use parking_lot::Mutex;
26use switchyard_protocol::{ModelId, Request, Role};
27
28use crate::core::algorithm::{Driver, RoutingIdentity};
29use crate::core::classifier::{Classification, Classifier, Score};
30use crate::core::processor::{Event, Processor};
31
32const MAX_ASSIGNMENTS: usize = 4096;
35
36#[derive(Default)]
47pub struct AffinityRouter {
48 latch_only: Option<HashSet<ModelId>>,
50 subagents_only: bool,
52 message_hash_fallback: bool,
54 assignments: Mutex<HashMap<RoutingIdentity, ModelId>>,
59 unkeyed_warning_emitted: AtomicBool,
61}
62
63impl AffinityRouter {
64 pub fn new() -> Self {
66 Self::default()
67 }
68
69 pub fn for_subagents() -> Self {
73 Self {
74 subagents_only: true,
75 ..Self::default()
76 }
77 }
78
79 pub fn with_message_hash_fallback(mut self) -> Self {
81 self.message_hash_fallback = true;
82 self
83 }
84
85 pub fn with_latch_only(mut self, models: impl IntoIterator<Item = impl Into<ModelId>>) -> Self {
88 self.latch_only = Some(models.into_iter().map(Into::into).collect());
89 self
90 }
91
92 fn should_latch(&self, model: &str) -> bool {
94 self.latch_only
95 .as_ref()
96 .is_none_or(|set| set.contains(model))
97 }
98
99 fn affinity_key(&self, request: &Request) -> Option<RoutingIdentity> {
101 let metadata = request.metadata.as_ref();
102 let is_subagent = metadata.is_some_and(|metadata| metadata.is_subagent);
103 if self.subagents_only && !is_subagent {
105 return None;
106 }
107
108 let key = match (RoutingIdentity::from_request(request), is_subagent) {
109 (Some(identity), _) => Some(identity),
110 (None, true) => None,
113 (None, false) if self.message_hash_fallback => {
114 first_user_message_hash(request).map(|hash| {
115 tracing::debug!(affinity_key = %hash, "affinity using message hash fallback");
116 RoutingIdentity::Session(hash)
117 })
118 }
119 (None, false) => None,
120 };
121 if key.is_none() && self.should_warn_unkeyed() {
124 tracing::warn!(
125 target: "libsy",
126 is_subagent,
127 message_hash_fallback = self.message_hash_fallback,
128 "affinity is enabled but this request carries no usable identity, so no \
129 affinity is applied; root requests need a session id or message-hash \
130 fallback with usable first-user text, and child requests need both session \
131 and agent ids"
132 );
133 }
134 key
135 }
136
137 fn should_warn_unkeyed(&self) -> bool {
139 !self.unkeyed_warning_emitted.swap(true, Ordering::Relaxed)
140 }
141}
142
143#[async_trait]
144impl<S> Processor<S> for AffinityRouter
145where
146 S: Send + 'static,
147{
148 async fn process(&self, _state: &mut S, event: Event<'_>) -> crate::Result<()> {
149 if let Event::Decision {
150 request,
151 selected_model_id,
152 } = event
153 && let Some(key) = self.affinity_key(request)
154 {
155 let mut assignments = self.assignments.lock();
156 if self.should_latch(selected_model_id) && !assignments.contains_key(&key) {
157 evict_if_full(&mut assignments);
158 assignments.insert(key, selected_model_id.clone());
159 }
160 }
161 Ok(())
162 }
163}
164
165fn first_user_message_hash(request: &Request) -> Option<String> {
169 let message = request
170 .llm_request
171 .messages
172 .iter()
173 .find(|message| message.role == Role::User)?;
174 let mut hasher = DefaultHasher::new();
175 message.text_content("")?.hash(&mut hasher);
176 Some(format!("{:016x}", hasher.finish()))
177}
178
179#[async_trait]
180impl<S> Classifier<S> for AffinityRouter
181where
182 S: Send + 'static,
183{
184 async fn score(
185 &self,
186 _state: &mut S,
187 request: &mut Request,
188 _driver: Option<&Driver>,
189 ) -> crate::Result<(Classification, Option<switchyard_protocol::Response>)> {
190 let Some(key) = self.affinity_key(request) else {
191 return Ok((Classification::Scores(Vec::new()), None));
192 };
193 let assigned = self.assignments.lock().get(&key).cloned();
194 Ok((
195 Classification::Scores(match assigned {
196 Some(target) => vec![Score {
197 confidence: 1.0,
198 target,
199 }],
200 None => Vec::new(),
201 }),
202 None,
203 ))
204 }
205}
206
207fn evict_if_full(assignments: &mut HashMap<RoutingIdentity, ModelId>) {
209 if assignments.len() >= MAX_ASSIGNMENTS
210 && let Some(evicted) = assignments.keys().next().cloned()
211 {
212 assignments.remove(&evicted);
213 }
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219
220 use std::sync::Arc;
221
222 use switchyard_protocol::{ContentBlock, LlmRequest, Message, Metadata, text_request};
223
224 type BoxErr = Box<dyn std::error::Error + Send + Sync>;
226
227 fn fixed_model(target: &str) -> ModelId {
228 ModelId::from(target)
229 }
230
231 fn request(metadata: Metadata) -> Request {
232 Request {
233 llm_request: text_request(Some("auto".to_string()), "hi"),
234 raw_request: None,
235 metadata: Some(metadata),
236 }
237 }
238
239 fn task_request(
240 metadata: Option<Metadata>,
241 first_user: &str,
242 follow_up: Option<&str>,
243 ) -> Request {
244 let mut messages = vec![
245 Message::text(Role::System, "follow repository instructions"),
246 Message::text(Role::User, first_user),
247 ];
248 if let Some(follow_up) = follow_up {
249 messages.push(Message::text(Role::Assistant, "I will inspect the code."));
250 messages.push(Message::text(Role::User, follow_up));
251 }
252 Request {
253 llm_request: LlmRequest {
254 model: Some("auto".to_string()),
255 messages,
256 ..LlmRequest::default()
257 },
258 raw_request: None,
259 metadata,
260 }
261 }
262
263 fn session(session_id: &str, agent_id: &str) -> Metadata {
264 Metadata {
265 session_id: Some(session_id.to_string()),
266 agent_id: Some(agent_id.to_string()),
267 ..Metadata::default()
268 }
269 }
270
271 fn subagent(agent_id: &str, task_id: &str) -> Metadata {
272 Metadata {
273 session_id: Some("session-1".to_string()),
274 agent_id: Some(agent_id.to_string()),
275 task_id: Some(task_id.to_string()),
276 is_subagent: true,
277 ..Metadata::default()
278 }
279 }
280
281 async fn retain(
283 router: &AffinityRouter,
284 state: &mut (),
285 request: &mut Request,
286 model: &'static str,
287 ) -> Result<(), BoxErr> {
288 let selected_model_id = ModelId::from(model);
289 router
290 .process(
291 state,
292 Event::Decision {
293 request,
294 selected_model_id: &selected_model_id,
295 },
296 )
297 .await?;
298 Ok(())
299 }
300
301 async fn scores(
303 classifier: &dyn Classifier,
304 state: &mut (),
305 request: &mut Request,
306 ) -> Result<Vec<Score>, BoxErr> {
307 match classifier.score(state, request, None).await?.0 {
308 Classification::Scores(scores) => Ok(scores),
309 Classification::Ambiguous(_) => Err("affinity never returns ambiguous scores".into()),
310 }
311 }
312
313 #[tokio::test]
314 async fn session_retains_first_model_across_requests() -> Result<(), BoxErr> {
315 let router = AffinityRouter::new();
316 let mut state = ();
317
318 let mut first = request(session("session-1", "agent-a"));
319 retain(&router, &mut state, &mut first, "model-a").await?;
320
321 let mut second = request(session("session-1", "agent-b"));
323 let scores = scores(&router, &mut state, &mut second).await?;
324 assert_eq!(scores.len(), 1);
325 assert_eq!(scores[0].confidence, 1.0);
326 assert_eq!(scores[0].target, "model-a");
327 Ok(())
328 }
329
330 #[tokio::test]
331 async fn subagent_only_retains_children_without_latching_root_traffic() -> Result<(), BoxErr> {
332 let router = AffinityRouter::for_subagents();
333 let mut state = ();
334
335 let mut root = request(session("session-1", "root-agent"));
336 retain(&router, &mut state, &mut root, "model-a").await?;
337 assert!(scores(&router, &mut state, &mut root).await?.is_empty());
338
339 let mut first_child_turn = request(subagent("child-1", "task-1"));
340 retain(&router, &mut state, &mut first_child_turn, "model-b").await?;
341 let mut later_child_turn = request(subagent("child-1", "task-2"));
342 let scores = scores(&router, &mut state, &mut later_child_turn).await?;
343 assert_eq!(
344 scores.first().map(|score| score.target.as_str()),
345 Some("model-b")
346 );
347 Ok(())
348 }
349
350 #[tokio::test]
351 async fn first_decision_wins() -> Result<(), BoxErr> {
352 let router = AffinityRouter::new();
353 let mut state = ();
354
355 let mut req = request(session("session-1", "agent-a"));
356 retain(&router, &mut state, &mut req, "model-a").await?;
357 retain(&router, &mut state, &mut req, "model-b").await?;
359
360 let scores = scores(&router, &mut state, &mut req).await?;
361 assert_eq!(
362 scores.first().map(|score| score.target.as_str()),
363 Some("model-a")
364 );
365 Ok(())
366 }
367
368 #[tokio::test]
369 async fn subagent_is_keyed_by_agent_not_task() -> Result<(), BoxErr> {
370 let router = AffinityRouter::new();
371 let mut state = ();
372
373 let mut first = request(subagent("child-1", "task-1"));
374 retain(&router, &mut state, &mut first, "model-a").await?;
375
376 let mut second = request(subagent("child-1", "task-2"));
378 let scores = scores(&router, &mut state, &mut second).await?;
379 assert_eq!(
380 scores.first().map(|score| score.target.as_str()),
381 Some("model-a")
382 );
383 Ok(())
384 }
385
386 #[tokio::test]
387 async fn distinct_subagents_are_assigned_independently() -> Result<(), BoxErr> {
388 let router = AffinityRouter::new();
389 let mut state = ();
390
391 retain(
393 &router,
394 &mut state,
395 &mut request(subagent("child-1", "task-1")),
396 "model-a",
397 )
398 .await?;
399
400 let mut sibling = request(subagent("child-2", "task-1"));
402 assert!(scores(&router, &mut state, &mut sibling).await?.is_empty());
403 Ok(())
404 }
405
406 #[tokio::test]
407 async fn subagent_does_not_inherit_session_assignment() -> Result<(), BoxErr> {
408 let router = AffinityRouter::new();
409 let mut state = ();
410
411 retain(
413 &router,
414 &mut state,
415 &mut request(session("session-1", "root-1")),
416 "model-a",
417 )
418 .await?;
419
420 let mut child = request(subagent("child-1", "task-1"));
422 assert!(scores(&router, &mut state, &mut child).await?.is_empty());
423 Ok(())
424 }
425
426 #[tokio::test]
427 async fn classifier_abstains_without_a_session() -> Result<(), BoxErr> {
428 let router = AffinityRouter::new();
429 let mut state = ();
430
431 let mut req = request(Metadata::default());
433 assert!(scores(&router, &mut state, &mut req).await?.is_empty());
434 Ok(())
435 }
436
437 #[tokio::test]
438 async fn message_hash_fallback_uses_the_first_user_message() -> Result<(), BoxErr> {
439 let router = AffinityRouter::new().with_message_hash_fallback();
440 let mut state = ();
441
442 let mut first = task_request(
443 None,
444 "Add a unit test for this function.",
445 Some("Now run the test suite."),
446 );
447 retain(&router, &mut state, &mut first, "weak").await?;
448
449 let mut follow_up = task_request(
450 None,
451 "Add a unit test for this function.",
452 Some("Now file a pull request."),
453 );
454 assert_eq!(
455 scores(&router, &mut state, &mut follow_up)
456 .await?
457 .first()
458 .map(|score| score.target.as_str()),
459 Some("weak")
460 );
461
462 let mut other_task = task_request(
463 None,
464 "Reimplement this binary from two input/output pairs.",
465 Some("Now run the test suite."),
466 );
467 assert!(
468 scores(&router, &mut state, &mut other_task)
469 .await?
470 .is_empty()
471 );
472 Ok(())
473 }
474
475 #[tokio::test]
476 async fn subagents_only_root_traffic_does_not_warn() -> Result<(), BoxErr> {
477 let router = AffinityRouter::for_subagents();
479 let mut state = ();
480
481 let mut root = request(session("session-1", "agent-1"));
482 assert!(scores(&router, &mut state, &mut root).await?.is_empty());
483 assert!(
484 router.should_warn_unkeyed(),
485 "an intentional abstention should leave the warning unconsumed"
486 );
487 Ok(())
488 }
489
490 #[test]
491 fn user_message_hash_ignores_non_text_provider_payloads() {
492 let request = |user_message| Request {
493 llm_request: LlmRequest {
494 messages: vec![user_message],
495 ..LlmRequest::default()
496 },
497 raw_request: None,
498 metadata: None,
499 };
500 let text_only = request(Message::text(Role::User, "Implement the parser."));
501 let text_with_reasoning = request(Message {
502 role: Role::User,
503 content: vec![
504 ContentBlock::Text {
505 text: "Implement the parser.".to_string(),
506 },
507 ContentBlock::Reasoning {
508 text: "Internal provider reasoning.".to_string(),
509 signature: Some("provider-signature".to_string()),
510 details: Vec::new(),
511 },
512 ],
513 });
514
515 assert_eq!(
516 first_user_message_hash(&text_only),
517 first_user_message_hash(&text_with_reasoning)
518 );
519 }
520
521 #[tokio::test]
522 async fn metadata_session_takes_precedence_over_message_hash() -> Result<(), BoxErr> {
523 let router = AffinityRouter::new().with_message_hash_fallback();
524 let mut state = ();
525
526 let mut first = task_request(
527 Some(session("session-1", "agent-a")),
528 "Implement the parser.",
529 None,
530 );
531 retain(&router, &mut state, &mut first, "strong").await?;
532
533 let mut other_session = task_request(
534 Some(session("session-2", "agent-a")),
535 "Implement the parser.",
536 None,
537 );
538 assert!(
539 scores(&router, &mut state, &mut other_session)
540 .await?
541 .is_empty()
542 );
543 Ok(())
544 }
545
546 #[tokio::test]
547 async fn subagent_without_a_session_abstains_and_warns() -> Result<(), BoxErr> {
548 for session_id in [None, Some(String::new())] {
549 let router = AffinityRouter::new().with_message_hash_fallback();
550 let mut state = ();
551 let mut subagent = task_request(
552 Some(Metadata {
553 session_id,
554 agent_id: Some("agent-1".to_string()),
555 is_subagent: true,
556 ..Metadata::default()
557 }),
558 "Implement the parser.",
559 None,
560 );
561
562 retain(&router, &mut state, &mut subagent, "model-a").await?;
563 assert!(scores(&router, &mut state, &mut subagent).await?.is_empty());
564 assert!(
565 !router.should_warn_unkeyed(),
566 "an unidentifiable subagent should consume the warning"
567 );
568 }
569 Ok(())
570 }
571
572 #[tokio::test]
573 async fn one_router_serves_both_roles() -> Result<(), BoxErr> {
574 let router = Arc::new(AffinityRouter::new());
577 let processor: Arc<dyn Processor> = router.clone();
578 let classifier: Arc<dyn Classifier> = router;
579 let mut state = ();
580
581 let mut first = request(session("session-1", "agent-a"));
582 processor
583 .process(
584 &mut state,
585 Event::Decision {
586 request: &mut first,
587 selected_model_id: &fixed_model("model-a"),
588 },
589 )
590 .await?;
591
592 let mut second = request(session("session-1", "agent-b"));
593 let scores = scores(classifier.as_ref(), &mut state, &mut second).await?;
594 assert_eq!(
595 scores.first().map(|score| score.target.as_str()),
596 Some("model-a")
597 );
598 Ok(())
599 }
600
601 #[tokio::test]
602 async fn decision_without_an_affinity_identity_is_ignored() -> Result<(), BoxErr> {
603 let router = AffinityRouter::new();
604 let mut state = ();
605 let mut unkeyed = request(Metadata::default());
606
607 router
608 .process(
609 &mut state,
610 Event::Decision {
611 request: &mut unkeyed,
612 selected_model_id: &fixed_model("model-a"),
613 },
614 )
615 .await?;
616
617 let mut req = request(session("session-1", "agent-a"));
618 assert!(scores(&router, &mut state, &mut req).await?.is_empty());
619 Ok(())
620 }
621
622 #[tokio::test]
623 async fn decisions_retain_their_originating_request_identity() -> Result<(), BoxErr> {
624 let router = AffinityRouter::new();
625 let mut state = ();
626 let mut first = request(session("session-1", "agent-a"));
627 let mut second = request(session("session-2", "agent-b"));
628
629 router
632 .process(
633 &mut state,
634 Event::Decision {
635 request: &mut second,
636 selected_model_id: &fixed_model("model-b"),
637 },
638 )
639 .await?;
640 router
641 .process(
642 &mut state,
643 Event::Decision {
644 request: &mut first,
645 selected_model_id: &fixed_model("model-a"),
646 },
647 )
648 .await?;
649
650 let first_scores = scores(&router, &mut state, &mut first).await?;
651 let second_scores = scores(&router, &mut state, &mut second).await?;
652 assert_eq!(
653 first_scores.first().map(|score| score.target.as_str()),
654 Some("model-a")
655 );
656 assert_eq!(
657 second_scores.first().map(|score| score.target.as_str()),
658 Some("model-b")
659 );
660 Ok(())
661 }
662
663 #[tokio::test]
664 async fn distinct_sessions_are_assigned_independently() -> Result<(), BoxErr> {
665 let router = AffinityRouter::new();
666 let mut state = ();
667
668 retain(
669 &router,
670 &mut state,
671 &mut request(session("session-1", "agent-a")),
672 "model-a",
673 )
674 .await?;
675 retain(
676 &router,
677 &mut state,
678 &mut request(session("session-2", "agent-a")),
679 "model-b",
680 )
681 .await?;
682
683 let first = scores(
684 &router,
685 &mut state,
686 &mut request(session("session-1", "other")),
687 )
688 .await?;
689 let second = scores(
690 &router,
691 &mut state,
692 &mut request(session("session-2", "other")),
693 )
694 .await?;
695 assert_eq!(
696 first.first().map(|score| score.target.as_str()),
697 Some("model-a")
698 );
699 assert_eq!(
700 second.first().map(|score| score.target.as_str()),
701 Some("model-b")
702 );
703 Ok(())
704 }
705
706 #[tokio::test]
707 async fn subagent_without_an_agent_id_abstains_and_warns() -> Result<(), BoxErr> {
708 let router = AffinityRouter::new();
709 let mut state = ();
710
711 let metadata = Metadata {
714 session_id: Some("session-1".to_string()),
715 is_subagent: true,
716 ..Metadata::default()
717 };
718 let mut req = request(metadata);
719 retain(&router, &mut state, &mut req, "model-a").await?;
720 assert!(scores(&router, &mut state, &mut req).await?.is_empty());
721 assert!(
722 !router.should_warn_unkeyed(),
723 "an unidentifiable subagent should consume the warning"
724 );
725 Ok(())
726 }
727
728 #[tokio::test]
729 async fn assignments_are_bounded_by_the_cap() -> Result<(), BoxErr> {
730 let router = AffinityRouter::new();
731 let mut state = ();
732
733 for index in 0..=MAX_ASSIGNMENTS {
735 let session_id = format!("session-{index}");
736 retain(
737 &router,
738 &mut state,
739 &mut request(session(&session_id, "agent-a")),
740 "model-a",
741 )
742 .await?;
743 }
744
745 let len = router.assignments.lock().len();
746 assert_eq!(len, MAX_ASSIGNMENTS);
747 Ok(())
748 }
749
750 #[tokio::test]
751 async fn latch_only_retains_matching_models() -> Result<(), BoxErr> {
752 let router = AffinityRouter::new().with_latch_only(["strong"]);
753 let mut state = ();
754 let mut req = request(session("session-1", "agent-a"));
755
756 retain(&router, &mut state, &mut req, "weak").await?;
758 assert!(scores(&router, &mut state, &mut req).await?.is_empty());
759
760 retain(&router, &mut state, &mut req, "strong").await?;
762 assert_eq!(
763 scores(&router, &mut state, &mut req)
764 .await?
765 .first()
766 .map(|s| s.target.as_str()),
767 Some("strong")
768 );
769 Ok(())
770 }
771}