Skip to main content

switchyard_libsy/algorithms/util/
affinity.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Model affinity as a single SDK component.
5//!
6//! [`AffinityRouter`] retains the first model chosen for a request's stable identity and
7//! forces that model on later requests sharing the identity. It is one object that plays
8//! both SDK roles, so registering it as a processor and a classifier cannot drift apart:
9//!
10//! - As a [`Processor`] it *writes* the assignment: [`Event::Decision`] carries the request
11//!   whose chosen model is retained, with the first assignment for an identity winning.
12//! - As a [`Classifier`] it *reads* the assignment: it scores the retained model with
13//!   confidence `1.0`, or returns no scores to abstain.
14//!
15//! Identity is derived from correlation metadata: by default, a request is keyed by its
16//! session, and a sub-agent is keyed more finely by `session + agent` — a subset of its
17//! session. [`AffinityRouter::for_subagents`] narrows affinity to explicitly identified
18//! child agents, leaving root traffic to later classifiers on every turn.
19
20use 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::{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};
32
33/// Upper bound on retained assignments, keeping the process-local map from growing
34/// without limit; the oldest entry is evicted once the bound is reached.
35const MAX_ASSIGNMENTS: usize = 4096;
36
37/// How often the classifier re-decides a session's target.
38#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
39#[serde(rename_all = "snake_case")]
40pub enum ClassifyTrigger {
41    /// Judge every request, tool continuations included.
42    #[default]
43    EveryRequest,
44    /// Judge each new user message, holding that target across the tool calls between.
45    UserTurn,
46    /// Judge once and reuse that target for the session.
47    NewSession,
48}
49
50/// Anthropic carries tool results as a `Role::User` message, so role alone cannot tell a
51/// human turn from a tool continuation.
52fn is_user_turn(message: &Message) -> bool {
53    message.role == Role::User
54        && !message
55            .content
56            .iter()
57            .all(|block| matches!(block, ContentBlock::ToolResult(_)))
58}
59
60fn has_new_user_turn(messages: &[Message]) -> bool {
61    messages.last().is_some_and(is_user_turn)
62}
63
64/// Retains a model per request identity and forces it on later matching requests.
65///
66/// Register the same instance as both a processor and a classifier; the two roles share
67/// the retained assignments through this instance's interior storage. Decision events carry
68/// their originating request, so concurrent turns cannot bind one request's identity to
69/// another request's selected model.
70///
71/// [`with_latch_only`](Self::with_latch_only) narrows *which* models are retained — a
72/// decision for any other model routes normally but is not latched (the escalation latch:
73/// retain only the strong tier, never the weak one).
74#[derive(Default)]
75pub struct AffinityRouter {
76    /// When set, only these models are retained; a decision for any other model is not latched.
77    latch_only: Option<HashSet<ModelId>>,
78    /// Whether root-session requests should abstain instead of being retained.
79    subagents_only: bool,
80    /// In absence of headers, use the message hash based fallback key to do task based routing
81    message_hash_fallback: bool,
82    /// Whether a new user message drops the assignment so the judge runs again.
83    release_on_user_turn: bool,
84    /// Retained assignments, shared across this router's processor and classifier roles.
85    ///
86    /// Held on the instance so the two roles share one process-local map through a
87    /// single registered [`Arc`](std::sync::Arc); bounded by [`MAX_ASSIGNMENTS`].
88    assignments: Mutex<HashMap<RoutingIdentity, ModelId>>,
89    /// Whether the "no identity to key on" warning has already been emitted.
90    unkeyed_warning_emitted: AtomicBool,
91}
92
93impl AffinityRouter {
94    /// Creates a router that latches every decision.
95    pub fn new() -> Self {
96        Self::default()
97    }
98
99    /// Creates a router that retains assignments only for explicitly identified child agents.
100    ///
101    /// Root-agent requests always abstain, so a later classifier selects them on every turn.
102    pub fn for_subagents() -> Self {
103        Self {
104            subagents_only: true,
105            ..Self::default()
106        }
107    }
108
109    /// Uses the first user-message text as a fallback key when metadata has no session.
110    pub fn with_message_hash_fallback(mut self) -> Self {
111        self.message_hash_fallback = true;
112        self
113    }
114
115    /// Re-decides per user turn, holding the target across the tool calls between.
116    pub fn with_release_on_user_turn(mut self) -> Self {
117        self.release_on_user_turn = true;
118        self
119    }
120
121    /// Restricts latching to `models`; a decision for any other model routes but is not
122    /// retained.
123    pub fn with_latch_only(mut self, models: impl IntoIterator<Item = impl Into<ModelId>>) -> Self {
124        self.latch_only = Some(models.into_iter().map(Into::into).collect());
125        self
126    }
127
128    /// Whether a decision for `model` should be retained.
129    fn should_latch(&self, model: &str) -> bool {
130        self.latch_only
131            .as_ref()
132            .is_none_or(|set| set.contains(model))
133    }
134
135    /// Derives the stable identity this router should retain for `request`.
136    fn affinity_key(&self, request: &Request) -> Option<RoutingIdentity> {
137        let metadata = request.metadata.as_ref();
138        let is_subagent = metadata.is_some_and(|metadata| metadata.is_subagent);
139        // This mode handles only subagent requests; root requests intentionally fall through.
140        if self.subagents_only && !is_subagent {
141            return None;
142        }
143
144        let key = match (RoutingIdentity::from_request(request), is_subagent) {
145            (Some(identity), _) => Some(identity),
146            // Child requests require their explicit session + agent identity and never fall
147            // back to task text.
148            (None, true) => None,
149            (None, false) if self.message_hash_fallback => {
150                first_user_message_hash(request).map(|hash| {
151                    tracing::debug!(affinity_key = %hash, "affinity using message hash fallback");
152                    RoutingIdentity::Session(hash)
153                })
154            }
155            (None, false) => None,
156        };
157        // Affinity that never keys anything is silent otherwise: the route reports itself as
158        // configured while every turn is classified afresh. Say so once rather than per turn.
159        if key.is_none() && self.should_warn_unkeyed() {
160            tracing::warn!(
161                target: "libsy",
162                is_subagent,
163                message_hash_fallback = self.message_hash_fallback,
164                "affinity is enabled but this request carries no usable identity, so no \
165                 affinity is applied; root requests need a session id or message-hash \
166                 fallback with usable first-user text, and child requests need both session \
167                 and agent ids"
168            );
169        }
170        key
171    }
172
173    /// Reports whether this call owns the one-time warning for an unkeyable request.
174    fn should_warn_unkeyed(&self) -> bool {
175        !self.unkeyed_warning_emitted.swap(true, Ordering::Relaxed)
176    }
177}
178
179#[async_trait]
180impl<S> Processor<S> for AffinityRouter
181where
182    S: Send + 'static,
183{
184    async fn process(&self, _state: &mut S, event: Event<'_>) -> crate::Result<()> {
185        if let Event::Decision {
186            request,
187            selected_model_id,
188        } = event
189            && let Some(key) = self.affinity_key(request)
190        {
191            let mut assignments = self.assignments.lock();
192            let writable = self.release_on_user_turn || !assignments.contains_key(&key);
193            if self.should_latch(selected_model_id) && writable {
194                evict_if_full(&mut assignments);
195                assignments.insert(key, selected_model_id.clone());
196            }
197        }
198        Ok(())
199    }
200}
201
202/// Hashes the first user message so later turns retain the initial task's affinity.
203/// For benchmarking purpose with harnesses, task instructions are added as a user prompt to the request so we hash the initial user message.
204/// TODO: Have not considered multi-modal payloads yet. That needs to be handled separately.
205fn first_user_message_hash(request: &Request) -> Option<String> {
206    let message = request
207        .llm_request
208        .messages
209        .iter()
210        .find(|message| message.role == Role::User)?;
211    let mut hasher = DefaultHasher::new();
212    message.text_content("")?.hash(&mut hasher);
213    Some(format!("{:016x}", hasher.finish()))
214}
215
216#[async_trait]
217impl<S> Classifier<S> for AffinityRouter
218where
219    S: Send + 'static,
220{
221    async fn score(
222        &self,
223        _state: &mut S,
224        request: &mut Request,
225        _driver: Option<&Driver>,
226    ) -> crate::Result<(Classification, Option<switchyard_protocol::Response>)> {
227        let Some(key) = self.affinity_key(request) else {
228            return Ok((Classification::Scores(Vec::new()), None));
229        };
230        // Abstaining rather than clearing keeps this read path free of writes, so the judge
231        // decides the turn whatever order concurrent latches land in.
232        if self.release_on_user_turn && has_new_user_turn(&request.llm_request.messages) {
233            return Ok((Classification::Scores(Vec::new()), None));
234        }
235        let assigned = self.assignments.lock().get(&key).cloned();
236        Ok((
237            Classification::Scores(match assigned {
238                Some(target) => vec![Score {
239                    confidence: 1.0,
240                    target,
241                }],
242                None => Vec::new(),
243            }),
244            None,
245        ))
246    }
247}
248
249/// Evicts one arbitrary assignment when the map has reached [`MAX_ASSIGNMENTS`].
250fn evict_if_full(assignments: &mut HashMap<RoutingIdentity, ModelId>) {
251    if assignments.len() >= MAX_ASSIGNMENTS
252        && let Some(evicted) = assignments.keys().next().cloned()
253    {
254        assignments.remove(&evicted);
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    use std::sync::Arc;
263
264    use switchyard_protocol::{LlmRequest, Metadata, ToolResult, text_request};
265
266    /// Boxed, thread-safe error type keeping the test helpers ergonomic.
267    type BoxErr = Box<dyn std::error::Error + Send + Sync>;
268
269    fn fixed_model(target: &str) -> ModelId {
270        ModelId::from(target)
271    }
272
273    fn request(metadata: Metadata) -> Request {
274        Request {
275            llm_request: text_request(Some("auto".to_string()), "hi"),
276            raw_request: None,
277            metadata: Some(metadata),
278        }
279    }
280
281    fn task_request(
282        metadata: Option<Metadata>,
283        first_user: &str,
284        follow_up: Option<&str>,
285    ) -> Request {
286        let mut messages = vec![
287            Message::text(Role::System, "follow repository instructions"),
288            Message::text(Role::User, first_user),
289        ];
290        if let Some(follow_up) = follow_up {
291            messages.push(Message::text(Role::Assistant, "I will inspect the code."));
292            messages.push(Message::text(Role::User, follow_up));
293        }
294        Request {
295            llm_request: LlmRequest {
296                model: Some("auto".to_string()),
297                messages,
298                ..LlmRequest::default()
299            },
300            raw_request: None,
301            metadata,
302        }
303    }
304
305    fn session(session_id: &str, agent_id: &str) -> Metadata {
306        Metadata {
307            session_id: Some(session_id.to_string()),
308            agent_id: Some(agent_id.to_string()),
309            ..Metadata::default()
310        }
311    }
312
313    fn subagent(agent_id: &str, task_id: &str) -> Metadata {
314        Metadata {
315            session_id: Some("session-1".to_string()),
316            agent_id: Some(agent_id.to_string()),
317            task_id: Some(task_id.to_string()),
318            is_subagent: true,
319            ..Metadata::default()
320        }
321    }
322
323    /// Folds a request and its decision through the router, retaining `model`.
324    async fn retain(
325        router: &AffinityRouter,
326        state: &mut (),
327        request: &mut Request,
328        model: &'static str,
329    ) -> Result<(), BoxErr> {
330        let selected_model_id = ModelId::from(model);
331        router
332            .process(
333                state,
334                Event::Decision {
335                    request,
336                    selected_model_id: &selected_model_id,
337                },
338            )
339            .await?;
340        Ok(())
341    }
342
343    /// Scores through the definitive classification variant used by affinity.
344    async fn scores(
345        classifier: &dyn Classifier,
346        state: &mut (),
347        request: &mut Request,
348    ) -> Result<Vec<Score>, BoxErr> {
349        match classifier.score(state, request, None).await?.0 {
350            Classification::Scores(scores) => Ok(scores),
351            Classification::Ambiguous(_) => Err("affinity never returns ambiguous scores".into()),
352        }
353    }
354
355    #[tokio::test]
356    async fn session_retains_first_model_across_requests() -> Result<(), BoxErr> {
357        let router = AffinityRouter::new();
358        let mut state = ();
359
360        let mut first = request(session("session-1", "agent-a"));
361        retain(&router, &mut state, &mut first, "model-a").await?;
362
363        // A different agent in the same session is scored onto the retained model.
364        let mut second = request(session("session-1", "agent-b"));
365        let scores = scores(&router, &mut state, &mut second).await?;
366        assert_eq!(scores.len(), 1);
367        assert_eq!(scores[0].confidence, 1.0);
368        assert_eq!(scores[0].target, "model-a");
369        Ok(())
370    }
371
372    #[tokio::test]
373    async fn subagent_only_retains_children_without_latching_root_traffic() -> Result<(), BoxErr> {
374        let router = AffinityRouter::for_subagents();
375        let mut state = ();
376
377        let mut root = request(session("session-1", "root-agent"));
378        retain(&router, &mut state, &mut root, "model-a").await?;
379        assert!(scores(&router, &mut state, &mut root).await?.is_empty());
380
381        let mut first_child_turn = request(subagent("child-1", "task-1"));
382        retain(&router, &mut state, &mut first_child_turn, "model-b").await?;
383        let mut later_child_turn = request(subagent("child-1", "task-2"));
384        let scores = scores(&router, &mut state, &mut later_child_turn).await?;
385        assert_eq!(
386            scores.first().map(|score| score.target.as_str()),
387            Some("model-b")
388        );
389        Ok(())
390    }
391
392    #[tokio::test]
393    async fn first_decision_wins() -> Result<(), BoxErr> {
394        let router = AffinityRouter::new();
395        let mut state = ();
396
397        let mut req = request(session("session-1", "agent-a"));
398        retain(&router, &mut state, &mut req, "model-a").await?;
399        // A later decision for the same identity must not overwrite the first.
400        retain(&router, &mut state, &mut req, "model-b").await?;
401
402        let scores = scores(&router, &mut state, &mut req).await?;
403        assert_eq!(
404            scores.first().map(|score| score.target.as_str()),
405            Some("model-a")
406        );
407        Ok(())
408    }
409
410    #[tokio::test]
411    async fn subagent_is_keyed_by_agent_not_task() -> Result<(), BoxErr> {
412        let router = AffinityRouter::new();
413        let mut state = ();
414
415        let mut first = request(subagent("child-1", "task-1"));
416        retain(&router, &mut state, &mut first, "model-a").await?;
417
418        // Same child, different task: still scored onto the retained model.
419        let mut second = request(subagent("child-1", "task-2"));
420        let scores = scores(&router, &mut state, &mut second).await?;
421        assert_eq!(
422            scores.first().map(|score| score.target.as_str()),
423            Some("model-a")
424        );
425        Ok(())
426    }
427
428    #[tokio::test]
429    async fn distinct_subagents_are_assigned_independently() -> Result<(), BoxErr> {
430        let router = AffinityRouter::new();
431        let mut state = ();
432
433        // One child in the session is pinned...
434        retain(
435            &router,
436            &mut state,
437            &mut request(subagent("child-1", "task-1")),
438            "model-a",
439        )
440        .await?;
441
442        // ...a sibling child in the same session has no assignment of its own yet.
443        let mut sibling = request(subagent("child-2", "task-1"));
444        assert!(scores(&router, &mut state, &mut sibling).await?.is_empty());
445        Ok(())
446    }
447
448    #[tokio::test]
449    async fn subagent_does_not_inherit_session_assignment() -> Result<(), BoxErr> {
450        let router = AffinityRouter::new();
451        let mut state = ();
452
453        // The session root is pinned, but a sub-agent is keyed separately...
454        retain(
455            &router,
456            &mut state,
457            &mut request(session("session-1", "root-1")),
458            "model-a",
459        )
460        .await?;
461
462        // ...so the sub-agent abstains until it is assigned in its own right.
463        let mut child = request(subagent("child-1", "task-1"));
464        assert!(scores(&router, &mut state, &mut child).await?.is_empty());
465        Ok(())
466    }
467
468    #[tokio::test]
469    async fn classifier_abstains_without_a_session() -> Result<(), BoxErr> {
470        let router = AffinityRouter::new();
471        let mut state = ();
472
473        // No session id at all: nothing to key on.
474        let mut req = request(Metadata::default());
475        assert!(scores(&router, &mut state, &mut req).await?.is_empty());
476        Ok(())
477    }
478
479    #[tokio::test]
480    async fn message_hash_fallback_uses_the_first_user_message() -> Result<(), BoxErr> {
481        let router = AffinityRouter::new().with_message_hash_fallback();
482        let mut state = ();
483
484        let mut first = task_request(
485            None,
486            "Add a unit test for this function.",
487            Some("Now run the test suite."),
488        );
489        retain(&router, &mut state, &mut first, "weak").await?;
490
491        let mut follow_up = task_request(
492            None,
493            "Add a unit test for this function.",
494            Some("Now file a pull request."),
495        );
496        assert_eq!(
497            scores(&router, &mut state, &mut follow_up)
498                .await?
499                .first()
500                .map(|score| score.target.as_str()),
501            Some("weak")
502        );
503
504        let mut other_task = task_request(
505            None,
506            "Reimplement this binary from two input/output pairs.",
507            Some("Now run the test suite."),
508        );
509        assert!(
510            scores(&router, &mut state, &mut other_task)
511                .await?
512                .is_empty()
513        );
514        Ok(())
515    }
516
517    #[tokio::test]
518    async fn subagents_only_root_traffic_does_not_warn() -> Result<(), BoxErr> {
519        // Abstaining on root traffic is this mode's contract, so it must not warn.
520        let router = AffinityRouter::for_subagents();
521        let mut state = ();
522
523        let mut root = request(session("session-1", "agent-1"));
524        assert!(scores(&router, &mut state, &mut root).await?.is_empty());
525        assert!(
526            router.should_warn_unkeyed(),
527            "an intentional abstention should leave the warning unconsumed"
528        );
529        Ok(())
530    }
531
532    #[test]
533    fn user_message_hash_ignores_non_text_provider_payloads() {
534        let request = |user_message| Request {
535            llm_request: LlmRequest {
536                messages: vec![user_message],
537                ..LlmRequest::default()
538            },
539            raw_request: None,
540            metadata: None,
541        };
542        let text_only = request(Message::text(Role::User, "Implement the parser."));
543        let text_with_reasoning = request(Message {
544            role: Role::User,
545            content: vec![
546                ContentBlock::Text {
547                    text: "Implement the parser.".to_string(),
548                },
549                ContentBlock::Reasoning {
550                    text: "Internal provider reasoning.".to_string(),
551                    signature: Some("provider-signature".to_string()),
552                    details: Vec::new(),
553                },
554            ],
555        });
556
557        assert_eq!(
558            first_user_message_hash(&text_only),
559            first_user_message_hash(&text_with_reasoning)
560        );
561    }
562
563    #[tokio::test]
564    async fn metadata_session_takes_precedence_over_message_hash() -> Result<(), BoxErr> {
565        let router = AffinityRouter::new().with_message_hash_fallback();
566        let mut state = ();
567
568        let mut first = task_request(
569            Some(session("session-1", "agent-a")),
570            "Implement the parser.",
571            None,
572        );
573        retain(&router, &mut state, &mut first, "strong").await?;
574
575        let mut other_session = task_request(
576            Some(session("session-2", "agent-a")),
577            "Implement the parser.",
578            None,
579        );
580        assert!(
581            scores(&router, &mut state, &mut other_session)
582                .await?
583                .is_empty()
584        );
585        Ok(())
586    }
587
588    #[tokio::test]
589    async fn subagent_without_a_session_abstains_and_warns() -> Result<(), BoxErr> {
590        for session_id in [None, Some(String::new())] {
591            let router = AffinityRouter::new().with_message_hash_fallback();
592            let mut state = ();
593            let mut subagent = task_request(
594                Some(Metadata {
595                    session_id,
596                    agent_id: Some("agent-1".to_string()),
597                    is_subagent: true,
598                    ..Metadata::default()
599                }),
600                "Implement the parser.",
601                None,
602            );
603
604            retain(&router, &mut state, &mut subagent, "model-a").await?;
605            assert!(scores(&router, &mut state, &mut subagent).await?.is_empty());
606            assert!(
607                !router.should_warn_unkeyed(),
608                "an unidentifiable subagent should consume the warning"
609            );
610        }
611        Ok(())
612    }
613
614    #[tokio::test]
615    async fn one_router_serves_both_roles() -> Result<(), BoxErr> {
616        // The same instance is registered under both SDK roles; a decision folded in via
617        // the processor handle is read back via the classifier handle.
618        let router = Arc::new(AffinityRouter::new());
619        let processor: Arc<dyn Processor> = router.clone();
620        let classifier: Arc<dyn Classifier> = router;
621        let mut state = ();
622
623        let mut first = request(session("session-1", "agent-a"));
624        processor
625            .process(
626                &mut state,
627                Event::Decision {
628                    request: &mut first,
629                    selected_model_id: &fixed_model("model-a"),
630                },
631            )
632            .await?;
633
634        let mut second = request(session("session-1", "agent-b"));
635        let scores = scores(classifier.as_ref(), &mut state, &mut second).await?;
636        assert_eq!(
637            scores.first().map(|score| score.target.as_str()),
638            Some("model-a")
639        );
640        Ok(())
641    }
642
643    #[tokio::test]
644    async fn decision_without_an_affinity_identity_is_ignored() -> Result<(), BoxErr> {
645        let router = AffinityRouter::new();
646        let mut state = ();
647        let mut unkeyed = request(Metadata::default());
648
649        router
650            .process(
651                &mut state,
652                Event::Decision {
653                    request: &mut unkeyed,
654                    selected_model_id: &fixed_model("model-a"),
655                },
656            )
657            .await?;
658
659        let mut req = request(session("session-1", "agent-a"));
660        assert!(scores(&router, &mut state, &mut req).await?.is_empty());
661        Ok(())
662    }
663
664    #[tokio::test]
665    async fn decisions_retain_their_originating_request_identity() -> Result<(), BoxErr> {
666        let router = AffinityRouter::new();
667        let mut state = ();
668        let mut first = request(session("session-1", "agent-a"));
669        let mut second = request(session("session-2", "agent-b"));
670
671        // Replay decisions in the opposite order. Each decision carries its request,
672        // so the assignments cannot cross.
673        router
674            .process(
675                &mut state,
676                Event::Decision {
677                    request: &mut second,
678                    selected_model_id: &fixed_model("model-b"),
679                },
680            )
681            .await?;
682        router
683            .process(
684                &mut state,
685                Event::Decision {
686                    request: &mut first,
687                    selected_model_id: &fixed_model("model-a"),
688                },
689            )
690            .await?;
691
692        let first_scores = scores(&router, &mut state, &mut first).await?;
693        let second_scores = scores(&router, &mut state, &mut second).await?;
694        assert_eq!(
695            first_scores.first().map(|score| score.target.as_str()),
696            Some("model-a")
697        );
698        assert_eq!(
699            second_scores.first().map(|score| score.target.as_str()),
700            Some("model-b")
701        );
702        Ok(())
703    }
704
705    #[tokio::test]
706    async fn distinct_sessions_are_assigned_independently() -> Result<(), BoxErr> {
707        let router = AffinityRouter::new();
708        let mut state = ();
709
710        retain(
711            &router,
712            &mut state,
713            &mut request(session("session-1", "agent-a")),
714            "model-a",
715        )
716        .await?;
717        retain(
718            &router,
719            &mut state,
720            &mut request(session("session-2", "agent-a")),
721            "model-b",
722        )
723        .await?;
724
725        let first = scores(
726            &router,
727            &mut state,
728            &mut request(session("session-1", "other")),
729        )
730        .await?;
731        let second = scores(
732            &router,
733            &mut state,
734            &mut request(session("session-2", "other")),
735        )
736        .await?;
737        assert_eq!(
738            first.first().map(|score| score.target.as_str()),
739            Some("model-a")
740        );
741        assert_eq!(
742            second.first().map(|score| score.target.as_str()),
743            Some("model-b")
744        );
745        Ok(())
746    }
747
748    #[tokio::test]
749    async fn subagent_without_an_agent_id_abstains_and_warns() -> Result<(), BoxErr> {
750        let router = AffinityRouter::new();
751        let mut state = ();
752
753        // The sub-agent flag is set but no agent id is present, so no key can be formed;
754        // the request is neither retained nor scored.
755        let metadata = Metadata {
756            session_id: Some("session-1".to_string()),
757            is_subagent: true,
758            ..Metadata::default()
759        };
760        let mut req = request(metadata);
761        retain(&router, &mut state, &mut req, "model-a").await?;
762        assert!(scores(&router, &mut state, &mut req).await?.is_empty());
763        assert!(
764            !router.should_warn_unkeyed(),
765            "an unidentifiable subagent should consume the warning"
766        );
767        Ok(())
768    }
769
770    #[tokio::test]
771    async fn assignments_are_bounded_by_the_cap() -> Result<(), BoxErr> {
772        let router = AffinityRouter::new();
773        let mut state = ();
774
775        // One distinct session past the cap forces exactly one eviction.
776        for index in 0..=MAX_ASSIGNMENTS {
777            let session_id = format!("session-{index}");
778            retain(
779                &router,
780                &mut state,
781                &mut request(session(&session_id, "agent-a")),
782                "model-a",
783            )
784            .await?;
785        }
786
787        let len = router.assignments.lock().len();
788        assert_eq!(len, MAX_ASSIGNMENTS);
789        Ok(())
790    }
791
792    #[tokio::test]
793    async fn release_on_user_turn_drops_the_assignment_only_when_the_user_speaks()
794    -> Result<(), BoxErr> {
795        let router = AffinityRouter::new().with_release_on_user_turn();
796        let mut state = ();
797        let mut opening = task_request(Some(session("session-1", "agent-a")), "add caching", None);
798        retain(&router, &mut state, &mut opening, "weak").await?;
799
800        // A tool continuation holds the assignment, so no judge call.
801        let mut continued = opening.clone();
802        continued.llm_request.messages.push(Message {
803            role: Role::User,
804            content: vec![ContentBlock::ToolResult(ToolResult {
805                tool_call_id: "call-1".to_string(),
806                content: Vec::new(),
807                is_error: None,
808            })],
809        });
810        assert_eq!(
811            scores(&router, &mut state, &mut continued)
812                .await?
813                .first()
814                .map(|s| s.target.as_str()),
815            Some("weak")
816        );
817
818        // A new user message releases it, so the turn abstains and the judge runs.
819        let mut spoke = task_request(
820            Some(session("session-1", "agent-a")),
821            "add caching",
822            Some("no, shared across processes"),
823        );
824        assert!(scores(&router, &mut state, &mut spoke).await?.is_empty());
825        Ok(())
826    }
827
828    #[tokio::test]
829    async fn latch_only_retains_matching_models() -> Result<(), BoxErr> {
830        let router = AffinityRouter::new().with_latch_only(["strong"]);
831        let mut state = ();
832        let mut req = request(session("session-1", "agent-a"));
833
834        // A "weak" decision is not retained — a later turn is not latched.
835        retain(&router, &mut state, &mut req, "weak").await?;
836        assert!(scores(&router, &mut state, &mut req).await?.is_empty());
837
838        // A "strong" decision is retained — later turns latch onto it.
839        retain(&router, &mut state, &mut req, "strong").await?;
840        assert_eq!(
841            scores(&router, &mut state, &mut req)
842                .await?
843                .first()
844                .map(|s| s.target.as_str()),
845            Some("strong")
846        );
847        Ok(())
848    }
849}