1use std::collections::{HashMap, HashSet, hash_map::DefaultHasher};
21use std::hash::{Hash, Hasher};
22
23use async_trait::async_trait;
24use parking_lot::Mutex;
25use switchyard_protocol::{Request, Role};
26
27use crate::core::algorithm::{Driver, RoutingIdentity};
28use crate::core::classifier::{Classification, Classifier, Score};
29use crate::core::processor::{Event, Processor};
30
31const MAX_ASSIGNMENTS: usize = 4096;
34
35#[derive(Default)]
46pub struct AffinityRouter {
47 latch_only: Option<HashSet<String>>,
49 subagents_only: bool,
51 message_hash_fallback: bool,
53 assignments: Mutex<HashMap<RoutingIdentity, String>>,
58}
59
60impl AffinityRouter {
61 pub fn new() -> Self {
63 Self::default()
64 }
65
66 pub fn for_subagents() -> Self {
70 Self {
71 subagents_only: true,
72 ..Self::default()
73 }
74 }
75
76 pub fn with_message_hash_fallback(mut self) -> Self {
78 self.message_hash_fallback = true;
79 self
80 }
81
82 pub fn with_latch_only(mut self, models: impl IntoIterator<Item = impl Into<String>>) -> Self {
85 self.latch_only = Some(models.into_iter().map(Into::into).collect());
86 self
87 }
88
89 fn should_latch(&self, model: &str) -> bool {
91 self.latch_only
92 .as_ref()
93 .is_none_or(|set| set.contains(model))
94 }
95
96 fn affinity_key(&self, request: &Request) -> Option<RoutingIdentity> {
98 if let Some(identity) = RoutingIdentity::from_request(request) {
99 return match identity {
100 RoutingIdentity::Session(_) if self.subagents_only => None,
101 identity => Some(identity),
102 };
103 }
104
105 let is_subagent = request
107 .metadata
108 .as_ref()
109 .is_some_and(|metadata| metadata.is_subagent);
110 (!self.subagents_only && !is_subagent && self.message_hash_fallback)
111 .then(|| {
112 first_user_message_hash(request).map(|hash| {
113 tracing::debug!(affinity_key = %hash, "affinity using message hash fallback");
114 RoutingIdentity::Session(hash)
115 })
116 })
117 .flatten()
118 }
119}
120
121#[async_trait]
122impl<S> Processor<S> for AffinityRouter
123where
124 S: Send + 'static,
125{
126 async fn process(&self, _state: &mut S, event: Event<'_>) -> crate::Result<()> {
127 if let Event::Decision { request, decision } = event
128 && let Some(key) = self.affinity_key(request)
129 {
130 let model = decision.selected_model();
131 let mut assignments = self.assignments.lock();
132 if self.should_latch(model) && !assignments.contains_key(&key) {
133 evict_if_full(&mut assignments);
134 assignments.insert(key, model.to_string());
135 }
136 }
137 Ok(())
138 }
139}
140
141fn first_user_message_hash(request: &Request) -> Option<String> {
145 let message = request
146 .llm_request
147 .messages
148 .iter()
149 .find(|message| message.role == Role::User)?;
150 let mut hasher = DefaultHasher::new();
151 message.text_content("")?.hash(&mut hasher);
152 Some(format!("{:016x}", hasher.finish()))
153}
154
155#[async_trait]
156impl<S> Classifier<S> for AffinityRouter
157where
158 S: Send + 'static,
159{
160 fn target_unavailable(&self, request: &Request, target: &str) {
161 let Some(key) = self.affinity_key(request) else {
162 return;
163 };
164 let mut assignments = self.assignments.lock();
165 if assignments
166 .get(&key)
167 .is_some_and(|assigned| assigned == target)
168 {
169 assignments.remove(&key);
170 }
171 }
172
173 async fn score(
174 &self,
175 _state: &mut S,
176 request: &mut Request,
177 _driver: Option<&Driver>,
178 ) -> crate::Result<(Classification, Option<switchyard_protocol::Response>)> {
179 let Some(key) = self.affinity_key(request) else {
180 return Ok((Classification::Scores(Vec::new()), None));
181 };
182 let assigned = self.assignments.lock().get(&key).cloned();
183 Ok((
184 Classification::Scores(match assigned {
185 Some(target) => vec![Score {
186 confidence: 1.0,
187 target,
188 }],
189 None => Vec::new(),
190 }),
191 None,
192 ))
193 }
194}
195
196fn evict_if_full(assignments: &mut HashMap<RoutingIdentity, String>) {
198 if assignments.len() >= MAX_ASSIGNMENTS
199 && let Some(evicted) = assignments.keys().next().cloned()
200 {
201 assignments.remove(&evicted);
202 }
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208
209 use std::sync::Arc;
210
211 use switchyard_protocol::{
212 ContentBlock, Decision, LlmRequest, Message, Metadata, text_request,
213 };
214
215 type BoxErr = Box<dyn std::error::Error + Send + Sync>;
217
218 struct FixedDecision(&'static str);
220
221 impl Decision for FixedDecision {
222 fn selected_model(&self) -> &str {
223 self.0
224 }
225
226 fn reasoning(&self) -> Option<&str> {
227 None
228 }
229
230 fn as_any(&self) -> &dyn std::any::Any {
231 self
232 }
233 }
234
235 fn request(metadata: Metadata) -> Request {
236 Request {
237 llm_request: text_request(Some("auto".to_string()), "hi"),
238 raw_request: None,
239 metadata: Some(metadata),
240 }
241 }
242
243 fn task_request(
244 metadata: Option<Metadata>,
245 first_user: &str,
246 follow_up: Option<&str>,
247 ) -> Request {
248 let mut messages = vec![
249 Message::text(Role::System, "follow repository instructions"),
250 Message::text(Role::User, first_user),
251 ];
252 if let Some(follow_up) = follow_up {
253 messages.push(Message::text(Role::Assistant, "I will inspect the code."));
254 messages.push(Message::text(Role::User, follow_up));
255 }
256 Request {
257 llm_request: LlmRequest {
258 model: Some("auto".to_string()),
259 messages,
260 ..LlmRequest::default()
261 },
262 raw_request: None,
263 metadata,
264 }
265 }
266
267 fn session(session_id: &str, agent_id: &str) -> Metadata {
268 Metadata {
269 session_id: Some(session_id.to_string()),
270 agent_id: Some(agent_id.to_string()),
271 ..Metadata::default()
272 }
273 }
274
275 fn subagent(agent_id: &str, task_id: &str) -> Metadata {
276 Metadata {
277 session_id: Some("session-1".to_string()),
278 agent_id: Some(agent_id.to_string()),
279 task_id: Some(task_id.to_string()),
280 is_subagent: true,
281 ..Metadata::default()
282 }
283 }
284
285 async fn retain(
287 router: &AffinityRouter,
288 state: &mut (),
289 request: &mut Request,
290 model: &'static str,
291 ) -> Result<(), BoxErr> {
292 router
293 .process(
294 state,
295 Event::Decision {
296 request,
297 decision: &FixedDecision(model),
298 },
299 )
300 .await?;
301 Ok(())
302 }
303
304 async fn scores(
306 classifier: &dyn Classifier,
307 state: &mut (),
308 request: &mut Request,
309 ) -> Result<Vec<Score>, BoxErr> {
310 match classifier.score(state, request, None).await?.0 {
311 Classification::Scores(scores) => Ok(scores),
312 Classification::Ambiguous(_) => Err("affinity never returns ambiguous scores".into()),
313 }
314 }
315
316 #[tokio::test]
317 async fn session_retains_first_model_across_requests() -> Result<(), BoxErr> {
318 let router = AffinityRouter::new();
319 let mut state = ();
320
321 let mut first = request(session("session-1", "agent-a"));
322 retain(&router, &mut state, &mut first, "model-a").await?;
323
324 let mut second = request(session("session-1", "agent-b"));
326 let scores = scores(&router, &mut state, &mut second).await?;
327 assert_eq!(scores.len(), 1);
328 assert_eq!(scores[0].confidence, 1.0);
329 assert_eq!(scores[0].target, "model-a");
330 Ok(())
331 }
332
333 #[tokio::test]
334 async fn subagent_only_retains_children_without_latching_root_traffic() -> Result<(), BoxErr> {
335 let router = AffinityRouter::for_subagents();
336 let mut state = ();
337
338 let mut root = request(session("session-1", "root-agent"));
339 retain(&router, &mut state, &mut root, "model-a").await?;
340 assert!(scores(&router, &mut state, &mut root).await?.is_empty());
341
342 let mut first_child_turn = request(subagent("child-1", "task-1"));
343 retain(&router, &mut state, &mut first_child_turn, "model-b").await?;
344 let mut later_child_turn = request(subagent("child-1", "task-2"));
345 let scores = scores(&router, &mut state, &mut later_child_turn).await?;
346 assert_eq!(
347 scores.first().map(|score| score.target.as_str()),
348 Some("model-b")
349 );
350 Ok(())
351 }
352
353 #[tokio::test]
354 async fn first_decision_wins() -> Result<(), BoxErr> {
355 let router = AffinityRouter::new();
356 let mut state = ();
357
358 let mut req = request(session("session-1", "agent-a"));
359 retain(&router, &mut state, &mut req, "model-a").await?;
360 retain(&router, &mut state, &mut req, "model-b").await?;
362
363 let scores = scores(&router, &mut state, &mut req).await?;
364 assert_eq!(
365 scores.first().map(|score| score.target.as_str()),
366 Some("model-a")
367 );
368 Ok(())
369 }
370
371 #[tokio::test]
372 async fn subagent_is_keyed_by_agent_not_task() -> Result<(), BoxErr> {
373 let router = AffinityRouter::new();
374 let mut state = ();
375
376 let mut first = request(subagent("child-1", "task-1"));
377 retain(&router, &mut state, &mut first, "model-a").await?;
378
379 let mut second = request(subagent("child-1", "task-2"));
381 let scores = scores(&router, &mut state, &mut second).await?;
382 assert_eq!(
383 scores.first().map(|score| score.target.as_str()),
384 Some("model-a")
385 );
386 Ok(())
387 }
388
389 #[tokio::test]
390 async fn distinct_subagents_are_assigned_independently() -> Result<(), BoxErr> {
391 let router = AffinityRouter::new();
392 let mut state = ();
393
394 retain(
396 &router,
397 &mut state,
398 &mut request(subagent("child-1", "task-1")),
399 "model-a",
400 )
401 .await?;
402
403 let mut sibling = request(subagent("child-2", "task-1"));
405 assert!(scores(&router, &mut state, &mut sibling).await?.is_empty());
406 Ok(())
407 }
408
409 #[tokio::test]
410 async fn subagent_does_not_inherit_session_assignment() -> Result<(), BoxErr> {
411 let router = AffinityRouter::new();
412 let mut state = ();
413
414 retain(
416 &router,
417 &mut state,
418 &mut request(session("session-1", "root-1")),
419 "model-a",
420 )
421 .await?;
422
423 let mut child = request(subagent("child-1", "task-1"));
425 assert!(scores(&router, &mut state, &mut child).await?.is_empty());
426 Ok(())
427 }
428
429 #[tokio::test]
430 async fn classifier_abstains_without_a_session() -> Result<(), BoxErr> {
431 let router = AffinityRouter::new();
432 let mut state = ();
433
434 let mut req = request(Metadata::default());
436 assert!(scores(&router, &mut state, &mut req).await?.is_empty());
437 Ok(())
438 }
439
440 #[tokio::test]
441 async fn message_hash_fallback_uses_the_first_user_message() -> Result<(), BoxErr> {
442 let router = AffinityRouter::new().with_message_hash_fallback();
443 let mut state = ();
444
445 let mut first = task_request(
446 None,
447 "Add a unit test for this function.",
448 Some("Now run the test suite."),
449 );
450 retain(&router, &mut state, &mut first, "weak").await?;
451
452 let mut follow_up = task_request(
453 None,
454 "Add a unit test for this function.",
455 Some("Now file a pull request."),
456 );
457 assert_eq!(
458 scores(&router, &mut state, &mut follow_up)
459 .await?
460 .first()
461 .map(|score| score.target.as_str()),
462 Some("weak")
463 );
464
465 let mut other_task = task_request(
466 None,
467 "Reimplement this binary from two input/output pairs.",
468 Some("Now run the test suite."),
469 );
470 assert!(
471 scores(&router, &mut state, &mut other_task)
472 .await?
473 .is_empty()
474 );
475 Ok(())
476 }
477
478 #[test]
479 fn user_message_hash_ignores_non_text_provider_payloads() {
480 let request = |user_message| Request {
481 llm_request: LlmRequest {
482 messages: vec![user_message],
483 ..LlmRequest::default()
484 },
485 raw_request: None,
486 metadata: None,
487 };
488 let text_only = request(Message::text(Role::User, "Implement the parser."));
489 let text_with_reasoning = request(Message {
490 role: Role::User,
491 content: vec![
492 ContentBlock::Text {
493 text: "Implement the parser.".to_string(),
494 },
495 ContentBlock::Reasoning {
496 text: "Internal provider reasoning.".to_string(),
497 signature: Some("provider-signature".to_string()),
498 },
499 ],
500 });
501
502 assert_eq!(
503 first_user_message_hash(&text_only),
504 first_user_message_hash(&text_with_reasoning)
505 );
506 }
507
508 #[tokio::test]
509 async fn metadata_session_takes_precedence_over_message_hash() -> Result<(), BoxErr> {
510 let router = AffinityRouter::new().with_message_hash_fallback();
511 let mut state = ();
512
513 let mut first = task_request(
514 Some(session("session-1", "agent-a")),
515 "Implement the parser.",
516 None,
517 );
518 retain(&router, &mut state, &mut first, "strong").await?;
519
520 let mut other_session = task_request(
521 Some(session("session-2", "agent-a")),
522 "Implement the parser.",
523 None,
524 );
525 assert!(
526 scores(&router, &mut state, &mut other_session)
527 .await?
528 .is_empty()
529 );
530 Ok(())
531 }
532
533 #[tokio::test]
534 async fn message_hash_fallback_abstains_for_subagents() -> Result<(), BoxErr> {
535 let router = AffinityRouter::new().with_message_hash_fallback();
536 let mut state = ();
537 let mut subagent = task_request(
538 Some(Metadata {
539 is_subagent: true,
540 ..Metadata::default()
541 }),
542 "Implement the parser.",
543 None,
544 );
545
546 assert!(scores(&router, &mut state, &mut subagent).await?.is_empty());
547 Ok(())
548 }
549
550 #[tokio::test]
551 async fn one_router_serves_both_roles() -> Result<(), BoxErr> {
552 let router = Arc::new(AffinityRouter::new());
555 let processor: Arc<dyn Processor> = router.clone();
556 let classifier: Arc<dyn Classifier> = router;
557 let mut state = ();
558
559 let mut first = request(session("session-1", "agent-a"));
560 processor
561 .process(
562 &mut state,
563 Event::Decision {
564 request: &mut first,
565 decision: &FixedDecision("model-a"),
566 },
567 )
568 .await?;
569
570 let mut second = request(session("session-1", "agent-b"));
571 let scores = scores(classifier.as_ref(), &mut state, &mut second).await?;
572 assert_eq!(
573 scores.first().map(|score| score.target.as_str()),
574 Some("model-a")
575 );
576 Ok(())
577 }
578
579 #[tokio::test]
580 async fn decision_without_an_affinity_identity_is_ignored() -> Result<(), BoxErr> {
581 let router = AffinityRouter::new();
582 let mut state = ();
583 let mut unkeyed = request(Metadata::default());
584
585 router
586 .process(
587 &mut state,
588 Event::Decision {
589 request: &mut unkeyed,
590 decision: &FixedDecision("model-a"),
591 },
592 )
593 .await?;
594
595 let mut req = request(session("session-1", "agent-a"));
596 assert!(scores(&router, &mut state, &mut req).await?.is_empty());
597 Ok(())
598 }
599
600 #[tokio::test]
601 async fn decisions_retain_their_originating_request_identity() -> Result<(), BoxErr> {
602 let router = AffinityRouter::new();
603 let mut state = ();
604 let mut first = request(session("session-1", "agent-a"));
605 let mut second = request(session("session-2", "agent-b"));
606
607 router
610 .process(
611 &mut state,
612 Event::Decision {
613 request: &mut second,
614 decision: &FixedDecision("model-b"),
615 },
616 )
617 .await?;
618 router
619 .process(
620 &mut state,
621 Event::Decision {
622 request: &mut first,
623 decision: &FixedDecision("model-a"),
624 },
625 )
626 .await?;
627
628 let first_scores = scores(&router, &mut state, &mut first).await?;
629 let second_scores = scores(&router, &mut state, &mut second).await?;
630 assert_eq!(
631 first_scores.first().map(|score| score.target.as_str()),
632 Some("model-a")
633 );
634 assert_eq!(
635 second_scores.first().map(|score| score.target.as_str()),
636 Some("model-b")
637 );
638 Ok(())
639 }
640
641 #[tokio::test]
642 async fn distinct_sessions_are_assigned_independently() -> Result<(), BoxErr> {
643 let router = AffinityRouter::new();
644 let mut state = ();
645
646 retain(
647 &router,
648 &mut state,
649 &mut request(session("session-1", "agent-a")),
650 "model-a",
651 )
652 .await?;
653 retain(
654 &router,
655 &mut state,
656 &mut request(session("session-2", "agent-a")),
657 "model-b",
658 )
659 .await?;
660
661 let first = scores(
662 &router,
663 &mut state,
664 &mut request(session("session-1", "other")),
665 )
666 .await?;
667 let second = scores(
668 &router,
669 &mut state,
670 &mut request(session("session-2", "other")),
671 )
672 .await?;
673 assert_eq!(
674 first.first().map(|score| score.target.as_str()),
675 Some("model-a")
676 );
677 assert_eq!(
678 second.first().map(|score| score.target.as_str()),
679 Some("model-b")
680 );
681 Ok(())
682 }
683
684 #[tokio::test]
685 async fn subagent_without_an_agent_id_is_not_keyed() -> Result<(), BoxErr> {
686 let router = AffinityRouter::new();
687 let mut state = ();
688
689 let metadata = Metadata {
692 session_id: Some("session-1".to_string()),
693 is_subagent: true,
694 ..Metadata::default()
695 };
696 let mut req = request(metadata);
697 retain(&router, &mut state, &mut req, "model-a").await?;
698 assert!(scores(&router, &mut state, &mut req).await?.is_empty());
699 Ok(())
700 }
701
702 #[tokio::test]
703 async fn assignments_are_bounded_by_the_cap() -> Result<(), BoxErr> {
704 let router = AffinityRouter::new();
705 let mut state = ();
706
707 for index in 0..=MAX_ASSIGNMENTS {
709 let session_id = format!("session-{index}");
710 retain(
711 &router,
712 &mut state,
713 &mut request(session(&session_id, "agent-a")),
714 "model-a",
715 )
716 .await?;
717 }
718
719 let len = router.assignments.lock().len();
720 assert_eq!(len, MAX_ASSIGNMENTS);
721 Ok(())
722 }
723
724 #[tokio::test]
725 async fn latch_only_retains_matching_models() -> Result<(), BoxErr> {
726 let router = AffinityRouter::new().with_latch_only(["strong"]);
727 let mut state = ();
728 let mut req = request(session("session-1", "agent-a"));
729
730 retain(&router, &mut state, &mut req, "weak").await?;
732 assert!(scores(&router, &mut state, &mut req).await?.is_empty());
733
734 retain(&router, &mut state, &mut req, "strong").await?;
736 assert_eq!(
737 scores(&router, &mut state, &mut req)
738 .await?
739 .first()
740 .map(|s| s.target.as_str()),
741 Some("strong")
742 );
743 Ok(())
744 }
745}