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::{Category, 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};
32use crate::core::state::State;
33
34const MAX_ASSIGNMENTS: usize = 4096;
37
38#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
40#[serde(rename_all = "snake_case")]
41pub enum ClassifyTrigger {
42 #[default]
44 EveryRequest,
45 UserTurn,
47 NewSession,
49}
50
51pub(crate) fn is_user_turn(message: &Message) -> bool {
53 message.role == Role::User
54 && message.content.iter().any(|block| {
55 !matches!(block, ContentBlock::ToolResult(_)) && !is_environment_context(block)
56 })
57}
58
59fn is_environment_context(block: &ContentBlock) -> bool {
60 matches!(block, ContentBlock::Text { text } if text.trim()
61 .strip_prefix("<environment_context>")
62 .and_then(|context| context.split_once("</environment_context>"))
63 .is_some_and(|(_, rest)| rest.trim().is_empty()))
64}
65
66fn is_environment_update(message: &Message) -> bool {
67 message.role == Role::User
68 && !message.content.is_empty()
69 && message.content.iter().all(is_environment_context)
70}
71
72pub(crate) fn is_turn_context(message: &Message) -> bool {
75 matches!(message.role, Role::System | Role::Developer) || is_environment_update(message)
76}
77
78pub(crate) fn has_new_user_turn(messages: &[Message]) -> bool {
79 messages
80 .iter()
81 .rev()
82 .find(|message| !is_turn_context(message))
83 .is_some_and(is_user_turn)
84}
85
86const INITIATOR_HEADER: &str = "x-initiator";
88
89pub(crate) fn initiated_by_user(request: &Request) -> Option<bool> {
93 let value = request
94 .metadata
95 .as_ref()?
96 .http_headers
97 .as_ref()?
98 .get(INITIATOR_HEADER)?
99 .to_str()
100 .ok()?
101 .trim();
102 if value.eq_ignore_ascii_case("user") {
103 Some(true)
104 } else if value.eq_ignore_ascii_case("agent") {
105 Some(false)
106 } else {
107 None
108 }
109}
110
111pub(crate) fn starts_user_turn(request: &Request) -> bool {
114 initiated_by_user(request).unwrap_or_else(|| has_new_user_turn(&request.llm_request.messages))
115}
116
117#[derive(Default)]
128pub struct AffinityRouter {
129 latch_only: Option<HashSet<ModelId>>,
131 subagents_only: bool,
133 message_hash_fallback: bool,
135 release_on_user_turn: bool,
137 assignments: Mutex<HashMap<RoutingIdentity, (ModelId, Option<Category>)>>,
142 unkeyed_warning_emitted: AtomicBool,
144}
145
146impl AffinityRouter {
147 pub fn new() -> Self {
149 Self::default()
150 }
151
152 pub fn for_subagents() -> Self {
157 Self {
158 subagents_only: true,
159 ..Self::default()
160 }
161 }
162
163 pub fn with_message_hash_fallback(mut self) -> Self {
165 self.message_hash_fallback = true;
166 self
167 }
168
169 pub fn with_release_on_user_turn(mut self) -> Self {
171 self.release_on_user_turn = true;
172 self
173 }
174
175 pub fn with_latch_only(mut self, models: impl IntoIterator<Item = impl Into<ModelId>>) -> Self {
178 self.latch_only = Some(models.into_iter().map(Into::into).collect());
179 self
180 }
181
182 fn should_latch(&self, model: &str) -> bool {
184 self.latch_only
185 .as_ref()
186 .is_none_or(|set| set.contains(model))
187 }
188
189 fn affinity_key(&self, request: &Request) -> Option<RoutingIdentity> {
191 let metadata = request.metadata.as_ref();
192 let is_subagent = metadata.is_some_and(|metadata| metadata.is_subagent);
193 let is_subagent_work = metadata.is_some_and(|metadata| metadata.is_subagent_work());
194 if self.subagents_only && !is_subagent_work {
196 return None;
197 }
198
199 let key = retention_key(request, self.message_hash_fallback && !is_subagent);
202 if key.is_none() && self.should_warn_unkeyed() {
205 tracing::warn!(
206 target: "libsy",
207 is_subagent,
208 message_hash_fallback = self.message_hash_fallback,
209 "affinity is enabled but this request carries no usable identity, so no \
210 affinity is applied; root requests need a session id or message-hash \
211 fallback with usable first-user text, and child requests need both session \
212 and agent ids"
213 );
214 }
215 key
216 }
217
218 fn should_warn_unkeyed(&self) -> bool {
220 !self.unkeyed_warning_emitted.swap(true, Ordering::Relaxed)
221 }
222}
223
224#[async_trait]
225impl Processor for AffinityRouter {
226 async fn process(&self, _state: &mut State, event: Event<'_>) -> crate::Result<()> {
227 if let Event::Decision {
228 request,
229 selected_model_id,
230 category,
231 ..
232 } = event
233 && let Some(key) = self.affinity_key(request)
234 {
235 let mut assignments = self.assignments.lock();
236 let writable = self.release_on_user_turn || !assignments.contains_key(&key);
237 if self.should_latch(selected_model_id) && writable {
238 evict_if_full(&mut assignments);
239 assignments.insert(key, (selected_model_id.clone(), category));
240 } else if self.release_on_user_turn && starts_user_turn(request) {
241 assignments.remove(&key);
244 }
245 }
246 Ok(())
247 }
248}
249
250pub(crate) fn retention_key(request: &Request, hash_fallback: bool) -> Option<RoutingIdentity> {
256 if let Some(identity) = RoutingIdentity::from_request(request) {
257 return Some(identity);
258 }
259 if !hash_fallback {
260 return None;
261 }
262 first_user_message_hash(request).map(|hash| {
263 tracing::debug!(retention_key = %hash, "retaining by message hash fallback");
264 RoutingIdentity::Session(hash)
265 })
266}
267
268fn first_user_message_hash(request: &Request) -> Option<String> {
272 let message = request
273 .llm_request
274 .messages
275 .iter()
276 .find(|message| message.role == Role::User)?;
277 let mut hasher = DefaultHasher::new();
278 message.text_content("")?.hash(&mut hasher);
279 Some(format!("{:016x}", hasher.finish()))
280}
281
282#[async_trait]
283impl Classifier for AffinityRouter {
284 async fn score(
285 &self,
286 _state: &mut State,
287 request: &mut Request,
288 driver: &Driver,
289 ) -> crate::Result<(Classification, Option<switchyard_protocol::Response>)> {
290 let Some(key) = self.affinity_key(request) else {
291 return Ok((Classification::Scores(Vec::new()), None));
292 };
293 if self.release_on_user_turn && starts_user_turn(request) {
296 return Ok((Classification::Scores(Vec::new()), None));
297 }
298 let available = driver.models_for(&Category::Any);
301 let mut assignments = self.assignments.lock();
302 let assigned = assignments.get(&key).cloned();
303 let assigned = match assigned.as_ref() {
304 Some((target, _)) if !available.is_empty() && !available.contains(target) => {
305 assignments.remove(&key);
306 None
307 }
308 assigned => assigned,
309 };
310 if assigned.is_some() {
311 driver.set_evidence(serde_json::json!({"source": "retained"}));
312 }
313 Ok((
314 Classification::Scores(match assigned {
315 Some((target, category)) => vec![Score {
316 confidence: 1.0,
317 target: target.clone(),
318 category: category.clone(),
319 }],
320 None => Vec::new(),
321 }),
322 None,
323 ))
324 }
325}
326
327pub(crate) fn evict_if_full<V>(retained: &mut HashMap<RoutingIdentity, V>) {
329 if retained.len() >= MAX_ASSIGNMENTS
330 && let Some(evicted) = retained.keys().next().cloned()
331 {
332 retained.remove(&evicted);
333 }
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339
340 use std::sync::Arc;
341
342 use switchyard_protocol::{LlmRequest, Metadata, ToolResult, text_request};
343
344 use crate::core::algorithm::RuntimeModels;
345
346 type BoxErr = Box<dyn std::error::Error + Send + Sync>;
348
349 #[test]
350 fn environment_updates_preserve_real_user_content() {
351 let context =
352 "<environment_context><current_date>2026-10-31</current_date></environment_context>";
353 assert!(!is_user_turn(&Message::text(
354 Role::User,
355 format!(" {context}\n")
356 )));
357 assert!(is_user_turn(&Message::text(
358 Role::User,
359 format!("{context}\nfix the parser")
360 )));
361 assert!(is_user_turn(&Message::text(
362 Role::User,
363 format!("explain this: {context}")
364 )));
365 let mut mixed = Message::text(Role::User, context);
366 mixed.content.push(ContentBlock::Text {
367 text: "fix the parser".into(),
368 });
369 assert!(is_user_turn(&mixed));
370 for (role, expected) in [
371 (Role::User, true),
372 (Role::Assistant, false),
373 (Role::Tool, false),
374 ] {
375 assert_eq!(
376 has_new_user_turn(&[
377 Message::text(role, "follow-up"),
378 Message::text(Role::User, context),
379 Message::text(Role::System, "System guidance"),
380 Message::text(Role::Developer, "Developer guidance"),
381 ]),
382 expected
383 );
384 }
385 }
386
387 fn fixed_model(target: &str) -> ModelId {
388 ModelId::from(target)
389 }
390
391 fn driver() -> Driver {
392 Driver::new(
393 "test",
394 Arc::new(RuntimeModels::new(
395 [(
396 Category::Any,
397 ["model-a", "model-b", "weak", "strong"]
398 .map(ModelId::from)
399 .to_vec(),
400 )]
401 .into(),
402 )),
403 )
404 .0
405 }
406
407 fn request(metadata: Metadata) -> Request {
408 Request {
409 llm_request: text_request(Some("auto".to_string()), "hi"),
410 raw_request: None,
411 metadata: Some(metadata),
412 }
413 }
414
415 fn task_request(
416 metadata: Option<Metadata>,
417 first_user: &str,
418 follow_up: Option<&str>,
419 ) -> Request {
420 let mut messages = vec![
421 Message::text(Role::System, "follow repository instructions"),
422 Message::text(Role::User, first_user),
423 ];
424 if let Some(follow_up) = follow_up {
425 messages.push(Message::text(Role::Assistant, "I will inspect the code."));
426 messages.push(Message::text(Role::User, follow_up));
427 }
428 Request {
429 llm_request: LlmRequest {
430 model: Some("auto".to_string()),
431 messages,
432 ..LlmRequest::default()
433 },
434 raw_request: None,
435 metadata,
436 }
437 }
438
439 fn session(session_id: &str, agent_id: &str) -> Metadata {
440 Metadata {
441 session_id: Some(session_id.to_string()),
442 agent_id: Some(agent_id.to_string()),
443 ..Metadata::default()
444 }
445 }
446
447 fn subagent(agent_id: &str, task_id: &str) -> Metadata {
448 Metadata {
449 session_id: Some("session-1".to_string()),
450 agent_id: Some(agent_id.to_string()),
451 task_id: Some(task_id.to_string()),
452 is_subagent: true,
453 is_delegated_work: true,
454 ..Metadata::default()
455 }
456 }
457
458 async fn retain(
460 router: &AffinityRouter,
461 state: &mut State,
462 request: &mut Request,
463 model: &'static str,
464 ) -> Result<(), BoxErr> {
465 let selected_model_id = ModelId::from(model);
466 router
467 .process(
468 state,
469 Event::Decision {
470 request,
471 selected_model_id: &selected_model_id,
472 category: None,
473 driver: &driver(),
474 },
475 )
476 .await?;
477 Ok(())
478 }
479
480 async fn scores(
482 classifier: &dyn Classifier,
483 state: &mut State,
484 request: &mut Request,
485 ) -> Result<Vec<Score>, BoxErr> {
486 match classifier.score(state, request, &driver()).await?.0 {
487 Classification::Scores(scores) => Ok(scores),
488 Classification::Ambiguous(_) => Err("affinity never returns ambiguous scores".into()),
489 }
490 }
491
492 #[tokio::test]
493 async fn session_retains_first_model_across_requests() -> Result<(), BoxErr> {
494 let router = AffinityRouter::new();
495 let mut state = State::default();
496
497 let mut first = request(session("session-1", "agent-a"));
498 retain(&router, &mut state, &mut first, "model-a").await?;
499
500 let mut second = request(session("session-1", "agent-b"));
502 let scores = scores(&router, &mut state, &mut second).await?;
503 assert_eq!(scores.len(), 1);
504 assert_eq!(scores[0].confidence, 1.0);
505 assert_eq!(scores[0].target, "model-a");
506 Ok(())
507 }
508
509 #[tokio::test]
510 async fn subagent_only_retains_children_without_latching_root_traffic() -> Result<(), BoxErr> {
511 let router = AffinityRouter::for_subagents();
512 let mut state = State::default();
513
514 let mut root = request(session("session-1", "root-agent"));
515 retain(&router, &mut state, &mut root, "model-a").await?;
516 assert!(scores(&router, &mut state, &mut root).await?.is_empty());
517
518 let mut first_child_turn = request(subagent("child-1", "task-1"));
519 retain(&router, &mut state, &mut first_child_turn, "model-b").await?;
520 let mut later_child_turn = request(subagent("child-1", "task-2"));
521 let scores = scores(&router, &mut state, &mut later_child_turn).await?;
522 assert_eq!(
523 scores.first().map(|score| score.target.as_str()),
524 Some("model-b")
525 );
526 Ok(())
527 }
528
529 #[tokio::test]
530 async fn first_decision_wins() -> Result<(), BoxErr> {
531 let router = AffinityRouter::new();
532 let mut state = State::default();
533
534 let mut req = request(session("session-1", "agent-a"));
535 retain(&router, &mut state, &mut req, "model-a").await?;
536 retain(&router, &mut state, &mut req, "model-b").await?;
538
539 let scores = scores(&router, &mut state, &mut req).await?;
540 assert_eq!(
541 scores.first().map(|score| score.target.as_str()),
542 Some("model-a")
543 );
544 Ok(())
545 }
546
547 #[tokio::test]
548 async fn subagent_is_keyed_by_agent_not_task() -> Result<(), BoxErr> {
549 let router = AffinityRouter::new();
550 let mut state = State::default();
551
552 let mut first = request(subagent("child-1", "task-1"));
553 retain(&router, &mut state, &mut first, "model-a").await?;
554
555 let mut second = request(subagent("child-1", "task-2"));
557 let scores = scores(&router, &mut state, &mut second).await?;
558 assert_eq!(
559 scores.first().map(|score| score.target.as_str()),
560 Some("model-a")
561 );
562 Ok(())
563 }
564
565 #[tokio::test]
566 async fn distinct_subagents_are_assigned_independently() -> Result<(), BoxErr> {
567 let router = AffinityRouter::new();
568 let mut state = State::default();
569
570 retain(
572 &router,
573 &mut state,
574 &mut request(subagent("child-1", "task-1")),
575 "model-a",
576 )
577 .await?;
578
579 let mut sibling = request(subagent("child-2", "task-1"));
581 assert!(scores(&router, &mut state, &mut sibling).await?.is_empty());
582 Ok(())
583 }
584
585 #[tokio::test]
586 async fn subagent_does_not_inherit_session_assignment() -> Result<(), BoxErr> {
587 let router = AffinityRouter::new();
588 let mut state = State::default();
589
590 retain(
592 &router,
593 &mut state,
594 &mut request(session("session-1", "root-1")),
595 "model-a",
596 )
597 .await?;
598
599 let mut child = request(subagent("child-1", "task-1"));
601 assert!(scores(&router, &mut state, &mut child).await?.is_empty());
602 Ok(())
603 }
604
605 #[tokio::test]
606 async fn classifier_abstains_without_a_session() -> Result<(), BoxErr> {
607 let router = AffinityRouter::new();
608 let mut state = State::default();
609
610 let mut req = request(Metadata::default());
612 assert!(scores(&router, &mut state, &mut req).await?.is_empty());
613 Ok(())
614 }
615
616 #[tokio::test]
617 async fn message_hash_fallback_uses_the_first_user_message() -> Result<(), BoxErr> {
618 let router = AffinityRouter::new().with_message_hash_fallback();
619 let mut state = State::default();
620
621 let mut first = task_request(
622 None,
623 "Add a unit test for this function.",
624 Some("Now run the test suite."),
625 );
626 retain(&router, &mut state, &mut first, "weak").await?;
627
628 let mut follow_up = task_request(
629 None,
630 "Add a unit test for this function.",
631 Some("Now file a pull request."),
632 );
633 assert_eq!(
634 scores(&router, &mut state, &mut follow_up)
635 .await?
636 .first()
637 .map(|score| score.target.as_str()),
638 Some("weak")
639 );
640
641 let mut other_task = task_request(
642 None,
643 "Reimplement this binary from two input/output pairs.",
644 Some("Now run the test suite."),
645 );
646 assert!(
647 scores(&router, &mut state, &mut other_task)
648 .await?
649 .is_empty()
650 );
651 Ok(())
652 }
653
654 #[tokio::test]
655 async fn subagents_only_root_traffic_does_not_warn() -> Result<(), BoxErr> {
656 let router = AffinityRouter::for_subagents();
658 let mut state = State::default();
659
660 let mut root = request(session("session-1", "agent-1"));
661 assert!(scores(&router, &mut state, &mut root).await?.is_empty());
662 assert!(
663 router.should_warn_unkeyed(),
664 "an intentional abstention should leave the warning unconsumed"
665 );
666 Ok(())
667 }
668
669 #[test]
670 fn user_message_hash_ignores_non_text_provider_payloads() {
671 let request = |user_message| Request {
672 llm_request: LlmRequest {
673 messages: vec![user_message],
674 ..LlmRequest::default()
675 },
676 raw_request: None,
677 metadata: None,
678 };
679 let text_only = request(Message::text(Role::User, "Implement the parser."));
680 let text_with_reasoning = request(Message {
681 role: Role::User,
682 content: vec![
683 ContentBlock::Text {
684 text: "Implement the parser.".to_string(),
685 },
686 ContentBlock::Reasoning {
687 text: "Internal provider reasoning.".to_string(),
688 signature: Some("provider-signature".to_string()),
689 details: Vec::new(),
690 },
691 ],
692 });
693
694 assert_eq!(
695 first_user_message_hash(&text_only),
696 first_user_message_hash(&text_with_reasoning)
697 );
698 }
699
700 #[tokio::test]
701 async fn metadata_session_takes_precedence_over_message_hash() -> Result<(), BoxErr> {
702 let router = AffinityRouter::new().with_message_hash_fallback();
703 let mut state = State::default();
704
705 let mut first = task_request(
706 Some(session("session-1", "agent-a")),
707 "Implement the parser.",
708 None,
709 );
710 retain(&router, &mut state, &mut first, "strong").await?;
711
712 let mut other_session = task_request(
713 Some(session("session-2", "agent-a")),
714 "Implement the parser.",
715 None,
716 );
717 assert!(
718 scores(&router, &mut state, &mut other_session)
719 .await?
720 .is_empty()
721 );
722 Ok(())
723 }
724
725 #[tokio::test]
726 async fn subagent_without_a_session_abstains_and_warns() -> Result<(), BoxErr> {
727 for session_id in [None, Some(String::new())] {
728 let router = AffinityRouter::new().with_message_hash_fallback();
729 let mut state = State::default();
730 let mut subagent = task_request(
731 Some(Metadata {
732 session_id,
733 agent_id: Some("agent-1".to_string()),
734 is_subagent: true,
735 ..Metadata::default()
736 }),
737 "Implement the parser.",
738 None,
739 );
740
741 retain(&router, &mut state, &mut subagent, "model-a").await?;
742 assert!(scores(&router, &mut state, &mut subagent).await?.is_empty());
743 assert!(
744 !router.should_warn_unkeyed(),
745 "an unidentifiable subagent should consume the warning"
746 );
747 }
748 Ok(())
749 }
750
751 #[tokio::test]
752 async fn one_router_serves_both_roles() -> Result<(), BoxErr> {
753 let router = Arc::new(AffinityRouter::new());
756 let processor: Arc<dyn Processor> = router.clone();
757 let classifier: Arc<dyn Classifier> = router;
758 let mut state = State::default();
759
760 let mut first = request(session("session-1", "agent-a"));
761 processor
762 .process(
763 &mut state,
764 Event::Decision {
765 request: &mut first,
766 selected_model_id: &fixed_model("model-a"),
767 category: None,
768 driver: &driver(),
769 },
770 )
771 .await?;
772
773 let mut second = request(session("session-1", "agent-b"));
774 let scores = scores(classifier.as_ref(), &mut state, &mut second).await?;
775 assert_eq!(
776 scores.first().map(|score| score.target.as_str()),
777 Some("model-a")
778 );
779 Ok(())
780 }
781
782 #[tokio::test]
783 async fn decision_without_an_affinity_identity_is_ignored() -> Result<(), BoxErr> {
784 let router = AffinityRouter::new();
785 let mut state = State::default();
786 let mut unkeyed = request(Metadata::default());
787
788 router
789 .process(
790 &mut state,
791 Event::Decision {
792 request: &mut unkeyed,
793 selected_model_id: &fixed_model("model-a"),
794 category: None,
795 driver: &driver(),
796 },
797 )
798 .await?;
799
800 let mut req = request(session("session-1", "agent-a"));
801 assert!(scores(&router, &mut state, &mut req).await?.is_empty());
802 Ok(())
803 }
804
805 #[tokio::test]
806 async fn decisions_retain_their_originating_request_identity() -> Result<(), BoxErr> {
807 let router = AffinityRouter::new();
808 let mut state = State::default();
809 let mut first = request(session("session-1", "agent-a"));
810 let mut second = request(session("session-2", "agent-b"));
811
812 router
815 .process(
816 &mut state,
817 Event::Decision {
818 request: &mut second,
819 selected_model_id: &fixed_model("model-b"),
820 category: None,
821 driver: &driver(),
822 },
823 )
824 .await?;
825 router
826 .process(
827 &mut state,
828 Event::Decision {
829 request: &mut first,
830 selected_model_id: &fixed_model("model-a"),
831 category: None,
832 driver: &driver(),
833 },
834 )
835 .await?;
836
837 let first_scores = scores(&router, &mut state, &mut first).await?;
838 let second_scores = scores(&router, &mut state, &mut second).await?;
839 assert_eq!(
840 first_scores.first().map(|score| score.target.as_str()),
841 Some("model-a")
842 );
843 assert_eq!(
844 second_scores.first().map(|score| score.target.as_str()),
845 Some("model-b")
846 );
847 Ok(())
848 }
849
850 #[tokio::test]
851 async fn distinct_sessions_are_assigned_independently() -> Result<(), BoxErr> {
852 let router = AffinityRouter::new();
853 let mut state = State::default();
854
855 retain(
856 &router,
857 &mut state,
858 &mut request(session("session-1", "agent-a")),
859 "model-a",
860 )
861 .await?;
862 retain(
863 &router,
864 &mut state,
865 &mut request(session("session-2", "agent-a")),
866 "model-b",
867 )
868 .await?;
869
870 let first = scores(
871 &router,
872 &mut state,
873 &mut request(session("session-1", "other")),
874 )
875 .await?;
876 let second = scores(
877 &router,
878 &mut state,
879 &mut request(session("session-2", "other")),
880 )
881 .await?;
882 assert_eq!(
883 first.first().map(|score| score.target.as_str()),
884 Some("model-a")
885 );
886 assert_eq!(
887 second.first().map(|score| score.target.as_str()),
888 Some("model-b")
889 );
890 Ok(())
891 }
892
893 #[tokio::test]
894 async fn subagent_without_an_agent_id_abstains_and_warns() -> Result<(), BoxErr> {
895 let router = AffinityRouter::new();
896 let mut state = State::default();
897
898 let metadata = Metadata {
901 session_id: Some("session-1".to_string()),
902 is_subagent: true,
903 ..Metadata::default()
904 };
905 let mut req = request(metadata);
906 retain(&router, &mut state, &mut req, "model-a").await?;
907 assert!(scores(&router, &mut state, &mut req).await?.is_empty());
908 assert!(
909 !router.should_warn_unkeyed(),
910 "an unidentifiable subagent should consume the warning"
911 );
912 Ok(())
913 }
914
915 #[tokio::test]
916 async fn assignments_are_bounded_by_the_cap() -> Result<(), BoxErr> {
917 let router = AffinityRouter::new();
918 let mut state = State::default();
919
920 for index in 0..=MAX_ASSIGNMENTS {
922 let session_id = format!("session-{index}");
923 retain(
924 &router,
925 &mut state,
926 &mut request(session(&session_id, "agent-a")),
927 "model-a",
928 )
929 .await?;
930 }
931
932 let len = router.assignments.lock().len();
933 assert_eq!(len, MAX_ASSIGNMENTS);
934 Ok(())
935 }
936
937 #[tokio::test]
938 async fn release_on_user_turn_drops_the_assignment_only_when_the_user_speaks()
939 -> Result<(), BoxErr> {
940 let router = AffinityRouter::new().with_release_on_user_turn();
941 let mut state = State::default();
942 let mut opening = task_request(Some(session("session-1", "agent-a")), "add caching", None);
943 retain(&router, &mut state, &mut opening, "weak").await?;
944
945 let mut continued = opening.clone();
947 continued.llm_request.messages.push(Message {
948 role: Role::User,
949 content: vec![ContentBlock::ToolResult(ToolResult {
950 tool_call_id: "call-1".to_string(),
951 content: Vec::new(),
952 is_error: None,
953 })],
954 });
955 assert_eq!(
956 scores(&router, &mut state, &mut continued)
957 .await?
958 .first()
959 .map(|s| s.target.as_str()),
960 Some("weak")
961 );
962
963 let mut spoke = task_request(
965 Some(session("session-1", "agent-a")),
966 "add caching",
967 Some("no, shared across processes"),
968 );
969 assert!(scores(&router, &mut state, &mut spoke).await?.is_empty());
970 Ok(())
971 }
972
973 #[tokio::test]
974 async fn a_user_turn_decided_for_an_unlatched_model_ends_the_previous_hold()
975 -> Result<(), BoxErr> {
976 let router = AffinityRouter::new()
977 .with_release_on_user_turn()
978 .with_latch_only(["strong"]);
979 let mut state = State::default();
980 let mut escalated =
981 task_request(Some(session("session-1", "agent-a")), "add caching", None);
982 retain(&router, &mut state, &mut escalated, "strong").await?;
983
984 let mut spoke = task_request(
985 Some(session("session-1", "agent-a")),
986 "add caching",
987 Some("now add a test"),
988 );
989 retain(&router, &mut state, &mut spoke, "weak").await?;
990
991 let mut continued = spoke.clone();
993 continued.llm_request.messages.push(Message {
994 role: Role::User,
995 content: vec![ContentBlock::ToolResult(ToolResult {
996 tool_call_id: "call-1".to_string(),
997 content: Vec::new(),
998 is_error: None,
999 })],
1000 });
1001 assert!(
1002 scores(&router, &mut state, &mut continued)
1003 .await?
1004 .is_empty()
1005 );
1006 Ok(())
1007 }
1008
1009 #[tokio::test]
1010 async fn latch_only_retains_matching_models() -> Result<(), BoxErr> {
1011 let router = AffinityRouter::new().with_latch_only(["strong"]);
1012 let mut state = State::default();
1013 let mut req = request(session("session-1", "agent-a"));
1014
1015 retain(&router, &mut state, &mut req, "weak").await?;
1017 assert!(scores(&router, &mut state, &mut req).await?.is_empty());
1018
1019 retain(&router, &mut state, &mut req, "strong").await?;
1021 assert_eq!(
1022 scores(&router, &mut state, &mut req)
1023 .await?
1024 .first()
1025 .map(|s| s.target.as_str()),
1026 Some("strong")
1027 );
1028 Ok(())
1029 }
1030
1031 #[tokio::test]
1032 async fn an_unprovisioned_any_group_does_not_evict_assignments() -> Result<(), BoxErr> {
1033 let router = AffinityRouter::new();
1034 let mut state = State::default();
1035 let mut req = request(session("session-1", "agent-a"));
1036 retain(&router, &mut state, &mut req, "model-a").await?;
1037
1038 let empty = Driver::new("test", Arc::new(RuntimeModels::default())).0;
1039 let (classification, _) = router.score(&mut state, &mut req, &empty).await?;
1040 let Classification::Scores(retained) = classification else {
1041 return Err("affinity never returns ambiguous scores".into());
1042 };
1043 assert_eq!(retained.first().map(|s| s.target.as_str()), Some("model-a"));
1044
1045 assert_eq!(
1047 scores(&router, &mut state, &mut req)
1048 .await?
1049 .first()
1050 .map(|s| s.target.as_str()),
1051 Some("model-a")
1052 );
1053 Ok(())
1054 }
1055}