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