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