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