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 if assigned.is_some()
247 && let Some(driver) = driver
248 {
249 driver.set_evidence(serde_json::json!({"source": "retained"}));
250 }
251 Ok((
252 Classification::Scores(match assigned {
253 Some(target) => vec![Score {
254 confidence: 1.0,
255 target,
256 }],
257 None => Vec::new(),
258 }),
259 None,
260 ))
261 }
262}
263
264pub(crate) fn evict_if_full<V>(retained: &mut HashMap<RoutingIdentity, V>) {
266 if retained.len() >= MAX_ASSIGNMENTS
267 && let Some(evicted) = retained.keys().next().cloned()
268 {
269 retained.remove(&evicted);
270 }
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276
277 use std::sync::Arc;
278
279 use switchyard_protocol::{LlmRequest, Metadata, ToolResult, text_request};
280
281 type BoxErr = Box<dyn std::error::Error + Send + Sync>;
283
284 fn fixed_model(target: &str) -> ModelId {
285 ModelId::from(target)
286 }
287
288 fn request(metadata: Metadata) -> Request {
289 Request {
290 llm_request: text_request(Some("auto".to_string()), "hi"),
291 raw_request: None,
292 metadata: Some(metadata),
293 }
294 }
295
296 fn task_request(
297 metadata: Option<Metadata>,
298 first_user: &str,
299 follow_up: Option<&str>,
300 ) -> Request {
301 let mut messages = vec![
302 Message::text(Role::System, "follow repository instructions"),
303 Message::text(Role::User, first_user),
304 ];
305 if let Some(follow_up) = follow_up {
306 messages.push(Message::text(Role::Assistant, "I will inspect the code."));
307 messages.push(Message::text(Role::User, follow_up));
308 }
309 Request {
310 llm_request: LlmRequest {
311 model: Some("auto".to_string()),
312 messages,
313 ..LlmRequest::default()
314 },
315 raw_request: None,
316 metadata,
317 }
318 }
319
320 fn session(session_id: &str, agent_id: &str) -> Metadata {
321 Metadata {
322 session_id: Some(session_id.to_string()),
323 agent_id: Some(agent_id.to_string()),
324 ..Metadata::default()
325 }
326 }
327
328 fn subagent(agent_id: &str, task_id: &str) -> Metadata {
329 Metadata {
330 session_id: Some("session-1".to_string()),
331 agent_id: Some(agent_id.to_string()),
332 task_id: Some(task_id.to_string()),
333 is_subagent: true,
334 is_delegated_work: true,
335 ..Metadata::default()
336 }
337 }
338
339 async fn retain(
341 router: &AffinityRouter,
342 state: &mut (),
343 request: &mut Request,
344 model: &'static str,
345 ) -> Result<(), BoxErr> {
346 let selected_model_id = ModelId::from(model);
347 router
348 .process(
349 state,
350 Event::Decision {
351 request,
352 selected_model_id: &selected_model_id,
353 },
354 )
355 .await?;
356 Ok(())
357 }
358
359 async fn scores(
361 classifier: &dyn Classifier,
362 state: &mut (),
363 request: &mut Request,
364 ) -> Result<Vec<Score>, BoxErr> {
365 match classifier.score(state, request, None).await?.0 {
366 Classification::Scores(scores) => Ok(scores),
367 Classification::Ambiguous(_) => Err("affinity never returns ambiguous scores".into()),
368 }
369 }
370
371 #[tokio::test]
372 async fn session_retains_first_model_across_requests() -> Result<(), BoxErr> {
373 let router = AffinityRouter::new();
374 let mut state = ();
375
376 let mut first = request(session("session-1", "agent-a"));
377 retain(&router, &mut state, &mut first, "model-a").await?;
378
379 let mut second = request(session("session-1", "agent-b"));
381 let scores = scores(&router, &mut state, &mut second).await?;
382 assert_eq!(scores.len(), 1);
383 assert_eq!(scores[0].confidence, 1.0);
384 assert_eq!(scores[0].target, "model-a");
385 Ok(())
386 }
387
388 #[tokio::test]
389 async fn subagent_only_retains_children_without_latching_root_traffic() -> Result<(), BoxErr> {
390 let router = AffinityRouter::for_subagents();
391 let mut state = ();
392
393 let mut root = request(session("session-1", "root-agent"));
394 retain(&router, &mut state, &mut root, "model-a").await?;
395 assert!(scores(&router, &mut state, &mut root).await?.is_empty());
396
397 let mut first_child_turn = request(subagent("child-1", "task-1"));
398 retain(&router, &mut state, &mut first_child_turn, "model-b").await?;
399 let mut later_child_turn = request(subagent("child-1", "task-2"));
400 let scores = scores(&router, &mut state, &mut later_child_turn).await?;
401 assert_eq!(
402 scores.first().map(|score| score.target.as_str()),
403 Some("model-b")
404 );
405 Ok(())
406 }
407
408 #[tokio::test]
409 async fn first_decision_wins() -> Result<(), BoxErr> {
410 let router = AffinityRouter::new();
411 let mut state = ();
412
413 let mut req = request(session("session-1", "agent-a"));
414 retain(&router, &mut state, &mut req, "model-a").await?;
415 retain(&router, &mut state, &mut req, "model-b").await?;
417
418 let scores = scores(&router, &mut state, &mut req).await?;
419 assert_eq!(
420 scores.first().map(|score| score.target.as_str()),
421 Some("model-a")
422 );
423 Ok(())
424 }
425
426 #[tokio::test]
427 async fn subagent_is_keyed_by_agent_not_task() -> Result<(), BoxErr> {
428 let router = AffinityRouter::new();
429 let mut state = ();
430
431 let mut first = request(subagent("child-1", "task-1"));
432 retain(&router, &mut state, &mut first, "model-a").await?;
433
434 let mut second = request(subagent("child-1", "task-2"));
436 let scores = scores(&router, &mut state, &mut second).await?;
437 assert_eq!(
438 scores.first().map(|score| score.target.as_str()),
439 Some("model-a")
440 );
441 Ok(())
442 }
443
444 #[tokio::test]
445 async fn distinct_subagents_are_assigned_independently() -> Result<(), BoxErr> {
446 let router = AffinityRouter::new();
447 let mut state = ();
448
449 retain(
451 &router,
452 &mut state,
453 &mut request(subagent("child-1", "task-1")),
454 "model-a",
455 )
456 .await?;
457
458 let mut sibling = request(subagent("child-2", "task-1"));
460 assert!(scores(&router, &mut state, &mut sibling).await?.is_empty());
461 Ok(())
462 }
463
464 #[tokio::test]
465 async fn subagent_does_not_inherit_session_assignment() -> Result<(), BoxErr> {
466 let router = AffinityRouter::new();
467 let mut state = ();
468
469 retain(
471 &router,
472 &mut state,
473 &mut request(session("session-1", "root-1")),
474 "model-a",
475 )
476 .await?;
477
478 let mut child = request(subagent("child-1", "task-1"));
480 assert!(scores(&router, &mut state, &mut child).await?.is_empty());
481 Ok(())
482 }
483
484 #[tokio::test]
485 async fn classifier_abstains_without_a_session() -> Result<(), BoxErr> {
486 let router = AffinityRouter::new();
487 let mut state = ();
488
489 let mut req = request(Metadata::default());
491 assert!(scores(&router, &mut state, &mut req).await?.is_empty());
492 Ok(())
493 }
494
495 #[tokio::test]
496 async fn message_hash_fallback_uses_the_first_user_message() -> Result<(), BoxErr> {
497 let router = AffinityRouter::new().with_message_hash_fallback();
498 let mut state = ();
499
500 let mut first = task_request(
501 None,
502 "Add a unit test for this function.",
503 Some("Now run the test suite."),
504 );
505 retain(&router, &mut state, &mut first, "weak").await?;
506
507 let mut follow_up = task_request(
508 None,
509 "Add a unit test for this function.",
510 Some("Now file a pull request."),
511 );
512 assert_eq!(
513 scores(&router, &mut state, &mut follow_up)
514 .await?
515 .first()
516 .map(|score| score.target.as_str()),
517 Some("weak")
518 );
519
520 let mut other_task = task_request(
521 None,
522 "Reimplement this binary from two input/output pairs.",
523 Some("Now run the test suite."),
524 );
525 assert!(
526 scores(&router, &mut state, &mut other_task)
527 .await?
528 .is_empty()
529 );
530 Ok(())
531 }
532
533 #[tokio::test]
534 async fn subagents_only_root_traffic_does_not_warn() -> Result<(), BoxErr> {
535 let router = AffinityRouter::for_subagents();
537 let mut state = ();
538
539 let mut root = request(session("session-1", "agent-1"));
540 assert!(scores(&router, &mut state, &mut root).await?.is_empty());
541 assert!(
542 router.should_warn_unkeyed(),
543 "an intentional abstention should leave the warning unconsumed"
544 );
545 Ok(())
546 }
547
548 #[test]
549 fn user_message_hash_ignores_non_text_provider_payloads() {
550 let request = |user_message| Request {
551 llm_request: LlmRequest {
552 messages: vec![user_message],
553 ..LlmRequest::default()
554 },
555 raw_request: None,
556 metadata: None,
557 };
558 let text_only = request(Message::text(Role::User, "Implement the parser."));
559 let text_with_reasoning = request(Message {
560 role: Role::User,
561 content: vec![
562 ContentBlock::Text {
563 text: "Implement the parser.".to_string(),
564 },
565 ContentBlock::Reasoning {
566 text: "Internal provider reasoning.".to_string(),
567 signature: Some("provider-signature".to_string()),
568 details: Vec::new(),
569 },
570 ],
571 });
572
573 assert_eq!(
574 first_user_message_hash(&text_only),
575 first_user_message_hash(&text_with_reasoning)
576 );
577 }
578
579 #[tokio::test]
580 async fn metadata_session_takes_precedence_over_message_hash() -> Result<(), BoxErr> {
581 let router = AffinityRouter::new().with_message_hash_fallback();
582 let mut state = ();
583
584 let mut first = task_request(
585 Some(session("session-1", "agent-a")),
586 "Implement the parser.",
587 None,
588 );
589 retain(&router, &mut state, &mut first, "strong").await?;
590
591 let mut other_session = task_request(
592 Some(session("session-2", "agent-a")),
593 "Implement the parser.",
594 None,
595 );
596 assert!(
597 scores(&router, &mut state, &mut other_session)
598 .await?
599 .is_empty()
600 );
601 Ok(())
602 }
603
604 #[tokio::test]
605 async fn subagent_without_a_session_abstains_and_warns() -> Result<(), BoxErr> {
606 for session_id in [None, Some(String::new())] {
607 let router = AffinityRouter::new().with_message_hash_fallback();
608 let mut state = ();
609 let mut subagent = task_request(
610 Some(Metadata {
611 session_id,
612 agent_id: Some("agent-1".to_string()),
613 is_subagent: true,
614 ..Metadata::default()
615 }),
616 "Implement the parser.",
617 None,
618 );
619
620 retain(&router, &mut state, &mut subagent, "model-a").await?;
621 assert!(scores(&router, &mut state, &mut subagent).await?.is_empty());
622 assert!(
623 !router.should_warn_unkeyed(),
624 "an unidentifiable subagent should consume the warning"
625 );
626 }
627 Ok(())
628 }
629
630 #[tokio::test]
631 async fn one_router_serves_both_roles() -> Result<(), BoxErr> {
632 let router = Arc::new(AffinityRouter::new());
635 let processor: Arc<dyn Processor> = router.clone();
636 let classifier: Arc<dyn Classifier> = router;
637 let mut state = ();
638
639 let mut first = request(session("session-1", "agent-a"));
640 processor
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 mut second = request(session("session-1", "agent-b"));
651 let scores = scores(classifier.as_ref(), &mut state, &mut second).await?;
652 assert_eq!(
653 scores.first().map(|score| score.target.as_str()),
654 Some("model-a")
655 );
656 Ok(())
657 }
658
659 #[tokio::test]
660 async fn decision_without_an_affinity_identity_is_ignored() -> Result<(), BoxErr> {
661 let router = AffinityRouter::new();
662 let mut state = ();
663 let mut unkeyed = request(Metadata::default());
664
665 router
666 .process(
667 &mut state,
668 Event::Decision {
669 request: &mut unkeyed,
670 selected_model_id: &fixed_model("model-a"),
671 },
672 )
673 .await?;
674
675 let mut req = request(session("session-1", "agent-a"));
676 assert!(scores(&router, &mut state, &mut req).await?.is_empty());
677 Ok(())
678 }
679
680 #[tokio::test]
681 async fn decisions_retain_their_originating_request_identity() -> Result<(), BoxErr> {
682 let router = AffinityRouter::new();
683 let mut state = ();
684 let mut first = request(session("session-1", "agent-a"));
685 let mut second = request(session("session-2", "agent-b"));
686
687 router
690 .process(
691 &mut state,
692 Event::Decision {
693 request: &mut second,
694 selected_model_id: &fixed_model("model-b"),
695 },
696 )
697 .await?;
698 router
699 .process(
700 &mut state,
701 Event::Decision {
702 request: &mut first,
703 selected_model_id: &fixed_model("model-a"),
704 },
705 )
706 .await?;
707
708 let first_scores = scores(&router, &mut state, &mut first).await?;
709 let second_scores = scores(&router, &mut state, &mut second).await?;
710 assert_eq!(
711 first_scores.first().map(|score| score.target.as_str()),
712 Some("model-a")
713 );
714 assert_eq!(
715 second_scores.first().map(|score| score.target.as_str()),
716 Some("model-b")
717 );
718 Ok(())
719 }
720
721 #[tokio::test]
722 async fn distinct_sessions_are_assigned_independently() -> Result<(), BoxErr> {
723 let router = AffinityRouter::new();
724 let mut state = ();
725
726 retain(
727 &router,
728 &mut state,
729 &mut request(session("session-1", "agent-a")),
730 "model-a",
731 )
732 .await?;
733 retain(
734 &router,
735 &mut state,
736 &mut request(session("session-2", "agent-a")),
737 "model-b",
738 )
739 .await?;
740
741 let first = scores(
742 &router,
743 &mut state,
744 &mut request(session("session-1", "other")),
745 )
746 .await?;
747 let second = scores(
748 &router,
749 &mut state,
750 &mut request(session("session-2", "other")),
751 )
752 .await?;
753 assert_eq!(
754 first.first().map(|score| score.target.as_str()),
755 Some("model-a")
756 );
757 assert_eq!(
758 second.first().map(|score| score.target.as_str()),
759 Some("model-b")
760 );
761 Ok(())
762 }
763
764 #[tokio::test]
765 async fn subagent_without_an_agent_id_abstains_and_warns() -> Result<(), BoxErr> {
766 let router = AffinityRouter::new();
767 let mut state = ();
768
769 let metadata = Metadata {
772 session_id: Some("session-1".to_string()),
773 is_subagent: true,
774 ..Metadata::default()
775 };
776 let mut req = request(metadata);
777 retain(&router, &mut state, &mut req, "model-a").await?;
778 assert!(scores(&router, &mut state, &mut req).await?.is_empty());
779 assert!(
780 !router.should_warn_unkeyed(),
781 "an unidentifiable subagent should consume the warning"
782 );
783 Ok(())
784 }
785
786 #[tokio::test]
787 async fn assignments_are_bounded_by_the_cap() -> Result<(), BoxErr> {
788 let router = AffinityRouter::new();
789 let mut state = ();
790
791 for index in 0..=MAX_ASSIGNMENTS {
793 let session_id = format!("session-{index}");
794 retain(
795 &router,
796 &mut state,
797 &mut request(session(&session_id, "agent-a")),
798 "model-a",
799 )
800 .await?;
801 }
802
803 let len = router.assignments.lock().len();
804 assert_eq!(len, MAX_ASSIGNMENTS);
805 Ok(())
806 }
807
808 #[tokio::test]
809 async fn release_on_user_turn_drops_the_assignment_only_when_the_user_speaks()
810 -> Result<(), BoxErr> {
811 let router = AffinityRouter::new().with_release_on_user_turn();
812 let mut state = ();
813 let mut opening = task_request(Some(session("session-1", "agent-a")), "add caching", None);
814 retain(&router, &mut state, &mut opening, "weak").await?;
815
816 let mut continued = opening.clone();
818 continued.llm_request.messages.push(Message {
819 role: Role::User,
820 content: vec![ContentBlock::ToolResult(ToolResult {
821 tool_call_id: "call-1".to_string(),
822 content: Vec::new(),
823 is_error: None,
824 })],
825 });
826 assert_eq!(
827 scores(&router, &mut state, &mut continued)
828 .await?
829 .first()
830 .map(|s| s.target.as_str()),
831 Some("weak")
832 );
833
834 let mut spoke = task_request(
836 Some(session("session-1", "agent-a")),
837 "add caching",
838 Some("no, shared across processes"),
839 );
840 assert!(scores(&router, &mut state, &mut spoke).await?.is_empty());
841 Ok(())
842 }
843
844 #[tokio::test]
845 async fn latch_only_retains_matching_models() -> Result<(), BoxErr> {
846 let router = AffinityRouter::new().with_latch_only(["strong"]);
847 let mut state = ();
848 let mut req = request(session("session-1", "agent-a"));
849
850 retain(&router, &mut state, &mut req, "weak").await?;
852 assert!(scores(&router, &mut state, &mut req).await?.is_empty());
853
854 retain(&router, &mut state, &mut req, "strong").await?;
856 assert_eq!(
857 scores(&router, &mut state, &mut req)
858 .await?
859 .first()
860 .map(|s| s.target.as_str()),
861 Some("strong")
862 );
863 Ok(())
864 }
865}