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