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 { 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.clone());
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 async fn score(
183 &self,
184 _state: &mut S,
185 request: &mut Request,
186 _driver: Option<&Driver>,
187 ) -> crate::Result<(Classification, Option<switchyard_protocol::Response>)> {
188 let Some(key) = self.affinity_key(request) else {
189 return Ok((Classification::Scores(Vec::new()), None));
190 };
191 let assigned = self.assignments.lock().get(&key).cloned();
192 Ok((
193 Classification::Scores(match assigned {
194 Some(target) => vec![Score {
195 confidence: 1.0,
196 target,
197 }],
198 None => Vec::new(),
199 }),
200 None,
201 ))
202 }
203}
204
205fn evict_if_full(assignments: &mut HashMap<RoutingIdentity, ModelId>) {
207 if assignments.len() >= MAX_ASSIGNMENTS
208 && let Some(evicted) = assignments.keys().next().cloned()
209 {
210 assignments.remove(&evicted);
211 }
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217
218 use std::sync::Arc;
219
220 use switchyard_protocol::{
221 ContentBlock, Decision, LlmRequest, Message, Metadata, text_request,
222 };
223
224 type BoxErr = Box<dyn std::error::Error + Send + Sync>;
226
227 fn fixed_decision(target: &str) -> Decision {
228 Decision::new(target, true)
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 decision = fixed_decision(model);
289 router
290 .process(
291 state,
292 Event::Decision {
293 request,
294 decision: &decision,
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 },
511 ],
512 });
513
514 assert_eq!(
515 first_user_message_hash(&text_only),
516 first_user_message_hash(&text_with_reasoning)
517 );
518 }
519
520 #[tokio::test]
521 async fn metadata_session_takes_precedence_over_message_hash() -> Result<(), BoxErr> {
522 let router = AffinityRouter::new().with_message_hash_fallback();
523 let mut state = ();
524
525 let mut first = task_request(
526 Some(session("session-1", "agent-a")),
527 "Implement the parser.",
528 None,
529 );
530 retain(&router, &mut state, &mut first, "strong").await?;
531
532 let mut other_session = task_request(
533 Some(session("session-2", "agent-a")),
534 "Implement the parser.",
535 None,
536 );
537 assert!(
538 scores(&router, &mut state, &mut other_session)
539 .await?
540 .is_empty()
541 );
542 Ok(())
543 }
544
545 #[tokio::test]
546 async fn subagent_without_a_session_abstains_and_warns() -> Result<(), BoxErr> {
547 for session_id in [None, Some(String::new())] {
548 let router = AffinityRouter::new().with_message_hash_fallback();
549 let mut state = ();
550 let mut subagent = task_request(
551 Some(Metadata {
552 session_id,
553 agent_id: Some("agent-1".to_string()),
554 is_subagent: true,
555 ..Metadata::default()
556 }),
557 "Implement the parser.",
558 None,
559 );
560
561 retain(&router, &mut state, &mut subagent, "model-a").await?;
562 assert!(scores(&router, &mut state, &mut subagent).await?.is_empty());
563 assert!(
564 !router.should_warn_unkeyed(),
565 "an unidentifiable subagent should consume the warning"
566 );
567 }
568 Ok(())
569 }
570
571 #[tokio::test]
572 async fn one_router_serves_both_roles() -> Result<(), BoxErr> {
573 let router = Arc::new(AffinityRouter::new());
576 let processor: Arc<dyn Processor> = router.clone();
577 let classifier: Arc<dyn Classifier> = router;
578 let mut state = ();
579
580 let mut first = request(session("session-1", "agent-a"));
581 processor
582 .process(
583 &mut state,
584 Event::Decision {
585 request: &mut first,
586 decision: &fixed_decision("model-a"),
587 },
588 )
589 .await?;
590
591 let mut second = request(session("session-1", "agent-b"));
592 let scores = scores(classifier.as_ref(), &mut state, &mut second).await?;
593 assert_eq!(
594 scores.first().map(|score| score.target.as_str()),
595 Some("model-a")
596 );
597 Ok(())
598 }
599
600 #[tokio::test]
601 async fn decision_without_an_affinity_identity_is_ignored() -> Result<(), BoxErr> {
602 let router = AffinityRouter::new();
603 let mut state = ();
604 let mut unkeyed = request(Metadata::default());
605
606 router
607 .process(
608 &mut state,
609 Event::Decision {
610 request: &mut unkeyed,
611 decision: &fixed_decision("model-a"),
612 },
613 )
614 .await?;
615
616 let mut req = request(session("session-1", "agent-a"));
617 assert!(scores(&router, &mut state, &mut req).await?.is_empty());
618 Ok(())
619 }
620
621 #[tokio::test]
622 async fn decisions_retain_their_originating_request_identity() -> Result<(), BoxErr> {
623 let router = AffinityRouter::new();
624 let mut state = ();
625 let mut first = request(session("session-1", "agent-a"));
626 let mut second = request(session("session-2", "agent-b"));
627
628 router
631 .process(
632 &mut state,
633 Event::Decision {
634 request: &mut second,
635 decision: &fixed_decision("model-b"),
636 },
637 )
638 .await?;
639 router
640 .process(
641 &mut state,
642 Event::Decision {
643 request: &mut first,
644 decision: &fixed_decision("model-a"),
645 },
646 )
647 .await?;
648
649 let first_scores = scores(&router, &mut state, &mut first).await?;
650 let second_scores = scores(&router, &mut state, &mut second).await?;
651 assert_eq!(
652 first_scores.first().map(|score| score.target.as_str()),
653 Some("model-a")
654 );
655 assert_eq!(
656 second_scores.first().map(|score| score.target.as_str()),
657 Some("model-b")
658 );
659 Ok(())
660 }
661
662 #[tokio::test]
663 async fn distinct_sessions_are_assigned_independently() -> Result<(), BoxErr> {
664 let router = AffinityRouter::new();
665 let mut state = ();
666
667 retain(
668 &router,
669 &mut state,
670 &mut request(session("session-1", "agent-a")),
671 "model-a",
672 )
673 .await?;
674 retain(
675 &router,
676 &mut state,
677 &mut request(session("session-2", "agent-a")),
678 "model-b",
679 )
680 .await?;
681
682 let first = scores(
683 &router,
684 &mut state,
685 &mut request(session("session-1", "other")),
686 )
687 .await?;
688 let second = scores(
689 &router,
690 &mut state,
691 &mut request(session("session-2", "other")),
692 )
693 .await?;
694 assert_eq!(
695 first.first().map(|score| score.target.as_str()),
696 Some("model-a")
697 );
698 assert_eq!(
699 second.first().map(|score| score.target.as_str()),
700 Some("model-b")
701 );
702 Ok(())
703 }
704
705 #[tokio::test]
706 async fn subagent_without_an_agent_id_abstains_and_warns() -> Result<(), BoxErr> {
707 let router = AffinityRouter::new();
708 let mut state = ();
709
710 let metadata = Metadata {
713 session_id: Some("session-1".to_string()),
714 is_subagent: true,
715 ..Metadata::default()
716 };
717 let mut req = request(metadata);
718 retain(&router, &mut state, &mut req, "model-a").await?;
719 assert!(scores(&router, &mut state, &mut req).await?.is_empty());
720 assert!(
721 !router.should_warn_unkeyed(),
722 "an unidentifiable subagent should consume the warning"
723 );
724 Ok(())
725 }
726
727 #[tokio::test]
728 async fn assignments_are_bounded_by_the_cap() -> Result<(), BoxErr> {
729 let router = AffinityRouter::new();
730 let mut state = ();
731
732 for index in 0..=MAX_ASSIGNMENTS {
734 let session_id = format!("session-{index}");
735 retain(
736 &router,
737 &mut state,
738 &mut request(session(&session_id, "agent-a")),
739 "model-a",
740 )
741 .await?;
742 }
743
744 let len = router.assignments.lock().len();
745 assert_eq!(len, MAX_ASSIGNMENTS);
746 Ok(())
747 }
748
749 #[tokio::test]
750 async fn latch_only_retains_matching_models() -> Result<(), BoxErr> {
751 let router = AffinityRouter::new().with_latch_only(["strong"]);
752 let mut state = ();
753 let mut req = request(session("session-1", "agent-a"));
754
755 retain(&router, &mut state, &mut req, "weak").await?;
757 assert!(scores(&router, &mut state, &mut req).await?.is_empty());
758
759 retain(&router, &mut state, &mut req, "strong").await?;
761 assert_eq!(
762 scores(&router, &mut state, &mut req)
763 .await?
764 .first()
765 .map(|s| s.target.as_str()),
766 Some("strong")
767 );
768 Ok(())
769 }
770}