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