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