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::{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<String>>,
50 subagents_only: bool,
52 message_hash_fallback: bool,
54 assignments: Mutex<HashMap<RoutingIdentity, String>>,
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<String>>) -> 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 { request, decision } = event
150 && let Some(key) = self.affinity_key(request)
151 {
152 let model = decision.selected_model_id();
153 let mut assignments = self.assignments.lock();
154 if self.should_latch(model) && !assignments.contains_key(&key) {
155 evict_if_full(&mut assignments);
156 assignments.insert(key, model.to_string());
157 }
158 }
159 Ok(())
160 }
161}
162
163fn first_user_message_hash(request: &Request) -> Option<String> {
167 let message = request
168 .llm_request
169 .messages
170 .iter()
171 .find(|message| message.role == Role::User)?;
172 let mut hasher = DefaultHasher::new();
173 message.text_content("")?.hash(&mut hasher);
174 Some(format!("{:016x}", hasher.finish()))
175}
176
177#[async_trait]
178impl<S> Classifier<S> for AffinityRouter
179where
180 S: Send + 'static,
181{
182 fn target_unavailable(&self, request: &Request, target: &str) {
183 let Some(key) = self.affinity_key(request) else {
184 return;
185 };
186 let mut assignments = self.assignments.lock();
187 if assignments
188 .get(&key)
189 .is_some_and(|assigned| assigned == target)
190 {
191 assignments.remove(&key);
192 }
193 }
194
195 async fn score(
196 &self,
197 _state: &mut S,
198 request: &mut Request,
199 _driver: Option<&Driver>,
200 ) -> crate::Result<(Classification, Option<switchyard_protocol::Response>)> {
201 let Some(key) = self.affinity_key(request) else {
202 return Ok((Classification::Scores(Vec::new()), None));
203 };
204 let assigned = self.assignments.lock().get(&key).cloned();
205 Ok((
206 Classification::Scores(match assigned {
207 Some(target) => vec![Score {
208 confidence: 1.0,
209 target,
210 }],
211 None => Vec::new(),
212 }),
213 None,
214 ))
215 }
216}
217
218fn evict_if_full(assignments: &mut HashMap<RoutingIdentity, String>) {
220 if assignments.len() >= MAX_ASSIGNMENTS
221 && let Some(evicted) = assignments.keys().next().cloned()
222 {
223 assignments.remove(&evicted);
224 }
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230
231 use std::sync::Arc;
232
233 use switchyard_protocol::{
234 ContentBlock, Decision, LlmRequest, Message, Metadata, text_request,
235 };
236
237 type BoxErr = Box<dyn std::error::Error + Send + Sync>;
239
240 fn fixed_decision(target: &str) -> Decision {
241 Decision::new(target, None, true)
242 }
243
244 fn request(metadata: Metadata) -> Request {
245 Request {
246 llm_request: text_request(Some("auto".to_string()), "hi"),
247 raw_request: None,
248 metadata: Some(metadata),
249 }
250 }
251
252 fn task_request(
253 metadata: Option<Metadata>,
254 first_user: &str,
255 follow_up: Option<&str>,
256 ) -> Request {
257 let mut messages = vec![
258 Message::text(Role::System, "follow repository instructions"),
259 Message::text(Role::User, first_user),
260 ];
261 if let Some(follow_up) = follow_up {
262 messages.push(Message::text(Role::Assistant, "I will inspect the code."));
263 messages.push(Message::text(Role::User, follow_up));
264 }
265 Request {
266 llm_request: LlmRequest {
267 model: Some("auto".to_string()),
268 messages,
269 ..LlmRequest::default()
270 },
271 raw_request: None,
272 metadata,
273 }
274 }
275
276 fn session(session_id: &str, agent_id: &str) -> Metadata {
277 Metadata {
278 session_id: Some(session_id.to_string()),
279 agent_id: Some(agent_id.to_string()),
280 ..Metadata::default()
281 }
282 }
283
284 fn subagent(agent_id: &str, task_id: &str) -> Metadata {
285 Metadata {
286 session_id: Some("session-1".to_string()),
287 agent_id: Some(agent_id.to_string()),
288 task_id: Some(task_id.to_string()),
289 is_subagent: true,
290 ..Metadata::default()
291 }
292 }
293
294 async fn retain(
296 router: &AffinityRouter,
297 state: &mut (),
298 request: &mut Request,
299 model: &'static str,
300 ) -> Result<(), BoxErr> {
301 let decision = fixed_decision(model);
302 router
303 .process(
304 state,
305 Event::Decision {
306 request,
307 decision: &decision,
308 },
309 )
310 .await?;
311 Ok(())
312 }
313
314 async fn scores(
316 classifier: &dyn Classifier,
317 state: &mut (),
318 request: &mut Request,
319 ) -> Result<Vec<Score>, BoxErr> {
320 match classifier.score(state, request, None).await?.0 {
321 Classification::Scores(scores) => Ok(scores),
322 Classification::Ambiguous(_) => Err("affinity never returns ambiguous scores".into()),
323 }
324 }
325
326 #[tokio::test]
327 async fn session_retains_first_model_across_requests() -> Result<(), BoxErr> {
328 let router = AffinityRouter::new();
329 let mut state = ();
330
331 let mut first = request(session("session-1", "agent-a"));
332 retain(&router, &mut state, &mut first, "model-a").await?;
333
334 let mut second = request(session("session-1", "agent-b"));
336 let scores = scores(&router, &mut state, &mut second).await?;
337 assert_eq!(scores.len(), 1);
338 assert_eq!(scores[0].confidence, 1.0);
339 assert_eq!(scores[0].target, "model-a");
340 Ok(())
341 }
342
343 #[tokio::test]
344 async fn subagent_only_retains_children_without_latching_root_traffic() -> Result<(), BoxErr> {
345 let router = AffinityRouter::for_subagents();
346 let mut state = ();
347
348 let mut root = request(session("session-1", "root-agent"));
349 retain(&router, &mut state, &mut root, "model-a").await?;
350 assert!(scores(&router, &mut state, &mut root).await?.is_empty());
351
352 let mut first_child_turn = request(subagent("child-1", "task-1"));
353 retain(&router, &mut state, &mut first_child_turn, "model-b").await?;
354 let mut later_child_turn = request(subagent("child-1", "task-2"));
355 let scores = scores(&router, &mut state, &mut later_child_turn).await?;
356 assert_eq!(
357 scores.first().map(|score| score.target.as_str()),
358 Some("model-b")
359 );
360 Ok(())
361 }
362
363 #[tokio::test]
364 async fn first_decision_wins() -> Result<(), BoxErr> {
365 let router = AffinityRouter::new();
366 let mut state = ();
367
368 let mut req = request(session("session-1", "agent-a"));
369 retain(&router, &mut state, &mut req, "model-a").await?;
370 retain(&router, &mut state, &mut req, "model-b").await?;
372
373 let scores = scores(&router, &mut state, &mut req).await?;
374 assert_eq!(
375 scores.first().map(|score| score.target.as_str()),
376 Some("model-a")
377 );
378 Ok(())
379 }
380
381 #[tokio::test]
382 async fn subagent_is_keyed_by_agent_not_task() -> Result<(), BoxErr> {
383 let router = AffinityRouter::new();
384 let mut state = ();
385
386 let mut first = request(subagent("child-1", "task-1"));
387 retain(&router, &mut state, &mut first, "model-a").await?;
388
389 let mut second = request(subagent("child-1", "task-2"));
391 let scores = scores(&router, &mut state, &mut second).await?;
392 assert_eq!(
393 scores.first().map(|score| score.target.as_str()),
394 Some("model-a")
395 );
396 Ok(())
397 }
398
399 #[tokio::test]
400 async fn distinct_subagents_are_assigned_independently() -> Result<(), BoxErr> {
401 let router = AffinityRouter::new();
402 let mut state = ();
403
404 retain(
406 &router,
407 &mut state,
408 &mut request(subagent("child-1", "task-1")),
409 "model-a",
410 )
411 .await?;
412
413 let mut sibling = request(subagent("child-2", "task-1"));
415 assert!(scores(&router, &mut state, &mut sibling).await?.is_empty());
416 Ok(())
417 }
418
419 #[tokio::test]
420 async fn subagent_does_not_inherit_session_assignment() -> Result<(), BoxErr> {
421 let router = AffinityRouter::new();
422 let mut state = ();
423
424 retain(
426 &router,
427 &mut state,
428 &mut request(session("session-1", "root-1")),
429 "model-a",
430 )
431 .await?;
432
433 let mut child = request(subagent("child-1", "task-1"));
435 assert!(scores(&router, &mut state, &mut child).await?.is_empty());
436 Ok(())
437 }
438
439 #[tokio::test]
440 async fn classifier_abstains_without_a_session() -> Result<(), BoxErr> {
441 let router = AffinityRouter::new();
442 let mut state = ();
443
444 let mut req = request(Metadata::default());
446 assert!(scores(&router, &mut state, &mut req).await?.is_empty());
447 Ok(())
448 }
449
450 #[tokio::test]
451 async fn message_hash_fallback_uses_the_first_user_message() -> Result<(), BoxErr> {
452 let router = AffinityRouter::new().with_message_hash_fallback();
453 let mut state = ();
454
455 let mut first = task_request(
456 None,
457 "Add a unit test for this function.",
458 Some("Now run the test suite."),
459 );
460 retain(&router, &mut state, &mut first, "weak").await?;
461
462 let mut follow_up = task_request(
463 None,
464 "Add a unit test for this function.",
465 Some("Now file a pull request."),
466 );
467 assert_eq!(
468 scores(&router, &mut state, &mut follow_up)
469 .await?
470 .first()
471 .map(|score| score.target.as_str()),
472 Some("weak")
473 );
474
475 let mut other_task = task_request(
476 None,
477 "Reimplement this binary from two input/output pairs.",
478 Some("Now run the test suite."),
479 );
480 assert!(
481 scores(&router, &mut state, &mut other_task)
482 .await?
483 .is_empty()
484 );
485 Ok(())
486 }
487
488 #[tokio::test]
489 async fn subagents_only_root_traffic_does_not_warn() -> Result<(), BoxErr> {
490 let router = AffinityRouter::for_subagents();
492 let mut state = ();
493
494 let mut root = request(session("session-1", "agent-1"));
495 assert!(scores(&router, &mut state, &mut root).await?.is_empty());
496 assert!(
497 router.should_warn_unkeyed(),
498 "an intentional abstention should leave the warning unconsumed"
499 );
500 Ok(())
501 }
502
503 #[test]
504 fn user_message_hash_ignores_non_text_provider_payloads() {
505 let request = |user_message| Request {
506 llm_request: LlmRequest {
507 messages: vec![user_message],
508 ..LlmRequest::default()
509 },
510 raw_request: None,
511 metadata: None,
512 };
513 let text_only = request(Message::text(Role::User, "Implement the parser."));
514 let text_with_reasoning = request(Message {
515 role: Role::User,
516 content: vec![
517 ContentBlock::Text {
518 text: "Implement the parser.".to_string(),
519 },
520 ContentBlock::Reasoning {
521 text: "Internal provider reasoning.".to_string(),
522 signature: Some("provider-signature".to_string()),
523 },
524 ],
525 });
526
527 assert_eq!(
528 first_user_message_hash(&text_only),
529 first_user_message_hash(&text_with_reasoning)
530 );
531 }
532
533 #[tokio::test]
534 async fn metadata_session_takes_precedence_over_message_hash() -> Result<(), BoxErr> {
535 let router = AffinityRouter::new().with_message_hash_fallback();
536 let mut state = ();
537
538 let mut first = task_request(
539 Some(session("session-1", "agent-a")),
540 "Implement the parser.",
541 None,
542 );
543 retain(&router, &mut state, &mut first, "strong").await?;
544
545 let mut other_session = task_request(
546 Some(session("session-2", "agent-a")),
547 "Implement the parser.",
548 None,
549 );
550 assert!(
551 scores(&router, &mut state, &mut other_session)
552 .await?
553 .is_empty()
554 );
555 Ok(())
556 }
557
558 #[tokio::test]
559 async fn subagent_without_a_session_abstains_and_warns() -> Result<(), BoxErr> {
560 for session_id in [None, Some(String::new())] {
561 let router = AffinityRouter::new().with_message_hash_fallback();
562 let mut state = ();
563 let mut subagent = task_request(
564 Some(Metadata {
565 session_id,
566 agent_id: Some("agent-1".to_string()),
567 is_subagent: true,
568 ..Metadata::default()
569 }),
570 "Implement the parser.",
571 None,
572 );
573
574 retain(&router, &mut state, &mut subagent, "model-a").await?;
575 assert!(scores(&router, &mut state, &mut subagent).await?.is_empty());
576 assert!(
577 !router.should_warn_unkeyed(),
578 "an unidentifiable subagent should consume the warning"
579 );
580 }
581 Ok(())
582 }
583
584 #[tokio::test]
585 async fn one_router_serves_both_roles() -> Result<(), BoxErr> {
586 let router = Arc::new(AffinityRouter::new());
589 let processor: Arc<dyn Processor> = router.clone();
590 let classifier: Arc<dyn Classifier> = router;
591 let mut state = ();
592
593 let mut first = request(session("session-1", "agent-a"));
594 processor
595 .process(
596 &mut state,
597 Event::Decision {
598 request: &mut first,
599 decision: &fixed_decision("model-a"),
600 },
601 )
602 .await?;
603
604 let mut second = request(session("session-1", "agent-b"));
605 let scores = scores(classifier.as_ref(), &mut state, &mut second).await?;
606 assert_eq!(
607 scores.first().map(|score| score.target.as_str()),
608 Some("model-a")
609 );
610 Ok(())
611 }
612
613 #[tokio::test]
614 async fn decision_without_an_affinity_identity_is_ignored() -> Result<(), BoxErr> {
615 let router = AffinityRouter::new();
616 let mut state = ();
617 let mut unkeyed = request(Metadata::default());
618
619 router
620 .process(
621 &mut state,
622 Event::Decision {
623 request: &mut unkeyed,
624 decision: &fixed_decision("model-a"),
625 },
626 )
627 .await?;
628
629 let mut req = request(session("session-1", "agent-a"));
630 assert!(scores(&router, &mut state, &mut req).await?.is_empty());
631 Ok(())
632 }
633
634 #[tokio::test]
635 async fn decisions_retain_their_originating_request_identity() -> Result<(), BoxErr> {
636 let router = AffinityRouter::new();
637 let mut state = ();
638 let mut first = request(session("session-1", "agent-a"));
639 let mut second = request(session("session-2", "agent-b"));
640
641 router
644 .process(
645 &mut state,
646 Event::Decision {
647 request: &mut second,
648 decision: &fixed_decision("model-b"),
649 },
650 )
651 .await?;
652 router
653 .process(
654 &mut state,
655 Event::Decision {
656 request: &mut first,
657 decision: &fixed_decision("model-a"),
658 },
659 )
660 .await?;
661
662 let first_scores = scores(&router, &mut state, &mut first).await?;
663 let second_scores = scores(&router, &mut state, &mut second).await?;
664 assert_eq!(
665 first_scores.first().map(|score| score.target.as_str()),
666 Some("model-a")
667 );
668 assert_eq!(
669 second_scores.first().map(|score| score.target.as_str()),
670 Some("model-b")
671 );
672 Ok(())
673 }
674
675 #[tokio::test]
676 async fn distinct_sessions_are_assigned_independently() -> Result<(), BoxErr> {
677 let router = AffinityRouter::new();
678 let mut state = ();
679
680 retain(
681 &router,
682 &mut state,
683 &mut request(session("session-1", "agent-a")),
684 "model-a",
685 )
686 .await?;
687 retain(
688 &router,
689 &mut state,
690 &mut request(session("session-2", "agent-a")),
691 "model-b",
692 )
693 .await?;
694
695 let first = scores(
696 &router,
697 &mut state,
698 &mut request(session("session-1", "other")),
699 )
700 .await?;
701 let second = scores(
702 &router,
703 &mut state,
704 &mut request(session("session-2", "other")),
705 )
706 .await?;
707 assert_eq!(
708 first.first().map(|score| score.target.as_str()),
709 Some("model-a")
710 );
711 assert_eq!(
712 second.first().map(|score| score.target.as_str()),
713 Some("model-b")
714 );
715 Ok(())
716 }
717
718 #[tokio::test]
719 async fn subagent_without_an_agent_id_abstains_and_warns() -> Result<(), BoxErr> {
720 let router = AffinityRouter::new();
721 let mut state = ();
722
723 let metadata = Metadata {
726 session_id: Some("session-1".to_string()),
727 is_subagent: true,
728 ..Metadata::default()
729 };
730 let mut req = request(metadata);
731 retain(&router, &mut state, &mut req, "model-a").await?;
732 assert!(scores(&router, &mut state, &mut req).await?.is_empty());
733 assert!(
734 !router.should_warn_unkeyed(),
735 "an unidentifiable subagent should consume the warning"
736 );
737 Ok(())
738 }
739
740 #[tokio::test]
741 async fn assignments_are_bounded_by_the_cap() -> Result<(), BoxErr> {
742 let router = AffinityRouter::new();
743 let mut state = ();
744
745 for index in 0..=MAX_ASSIGNMENTS {
747 let session_id = format!("session-{index}");
748 retain(
749 &router,
750 &mut state,
751 &mut request(session(&session_id, "agent-a")),
752 "model-a",
753 )
754 .await?;
755 }
756
757 let len = router.assignments.lock().len();
758 assert_eq!(len, MAX_ASSIGNMENTS);
759 Ok(())
760 }
761
762 #[tokio::test]
763 async fn latch_only_retains_matching_models() -> Result<(), BoxErr> {
764 let router = AffinityRouter::new().with_latch_only(["strong"]);
765 let mut state = ();
766 let mut req = request(session("session-1", "agent-a"));
767
768 retain(&router, &mut state, &mut req, "weak").await?;
770 assert!(scores(&router, &mut state, &mut req).await?.is_empty());
771
772 retain(&router, &mut state, &mut req, "strong").await?;
774 assert_eq!(
775 scores(&router, &mut state, &mut req)
776 .await?
777 .first()
778 .map(|s| s.target.as_str()),
779 Some("strong")
780 );
781 Ok(())
782 }
783}