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 and category chosen for a request's stable identity
7//! and forces that decision 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 delegated child-agent
18//! work, leaving root and harness-maintenance 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::{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};
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
60pub(crate) fn 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 requests other than delegated sub-agent work should abstain.
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, Option<Category>)>>,
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 delegated child-agent work.
100    ///
101    /// Root and harness-maintenance requests always abstain, so a later classifier selects
102    /// them on every turn.
103    pub fn for_subagents() -> Self {
104        Self {
105            subagents_only: true,
106            ..Self::default()
107        }
108    }
109
110    /// Uses the first user-message text as a fallback key when metadata has no session.
111    pub fn with_message_hash_fallback(mut self) -> Self {
112        self.message_hash_fallback = true;
113        self
114    }
115
116    /// Re-decides per user turn, holding the target across the tool calls between.
117    pub fn with_release_on_user_turn(mut self) -> Self {
118        self.release_on_user_turn = true;
119        self
120    }
121
122    /// Restricts latching to `models`; a decision for any other model routes but is not
123    /// retained.
124    pub fn with_latch_only(mut self, models: impl IntoIterator<Item = impl Into<ModelId>>) -> Self {
125        self.latch_only = Some(models.into_iter().map(Into::into).collect());
126        self
127    }
128
129    /// Whether a decision for `model` should be retained.
130    fn should_latch(&self, model: &str) -> bool {
131        self.latch_only
132            .as_ref()
133            .is_none_or(|set| set.contains(model))
134    }
135
136    /// Derives the stable identity this router should retain for `request`.
137    fn affinity_key(&self, request: &Request) -> Option<RoutingIdentity> {
138        let metadata = request.metadata.as_ref();
139        let is_subagent = metadata.is_some_and(|metadata| metadata.is_subagent);
140        let is_subagent_work = metadata.is_some_and(|metadata| metadata.is_subagent_work());
141        // This mode handles only delegated work; root and maintenance requests fall through.
142        if self.subagents_only && !is_subagent_work {
143            return None;
144        }
145
146        // Child requests require their explicit session + agent identity and never fall
147        // back to task text.
148        let key = retention_key(request, self.message_hash_fallback && !is_subagent);
149        // Affinity that never keys anything is silent otherwise: the route reports itself as
150        // configured while every turn is classified afresh. Say so once rather than per turn.
151        if key.is_none() && self.should_warn_unkeyed() {
152            tracing::warn!(
153                target: "libsy",
154                is_subagent,
155                message_hash_fallback = self.message_hash_fallback,
156                "affinity is enabled but this request carries no usable identity, so no \
157                 affinity is applied; root requests need a session id or message-hash \
158                 fallback with usable first-user text, and child requests need both session \
159                 and agent ids"
160            );
161        }
162        key
163    }
164
165    /// Reports whether this call owns the one-time warning for an unkeyable request.
166    fn should_warn_unkeyed(&self) -> bool {
167        !self.unkeyed_warning_emitted.swap(true, Ordering::Relaxed)
168    }
169}
170
171#[async_trait]
172impl<S> Processor<S> for AffinityRouter
173where
174    S: Send + 'static,
175{
176    async fn process(&self, _state: &mut S, event: Event<'_>) -> crate::Result<()> {
177        if let Event::Decision {
178            request,
179            selected_model_id,
180            category,
181            ..
182        } = event
183            && let Some(key) = self.affinity_key(request)
184        {
185            let mut assignments = self.assignments.lock();
186            let writable = self.release_on_user_turn || !assignments.contains_key(&key);
187            if self.should_latch(selected_model_id) && writable {
188                evict_if_full(&mut assignments);
189                assignments.insert(key, (selected_model_id.clone(), category));
190            }
191        }
192        Ok(())
193    }
194}
195
196/// The key a request's retained value is stored under.
197///
198/// Prefers the request's own identity and falls back to hashing the first user
199/// message when the caller allows it, which is the only handle on a client that
200/// sends no session ID.
201pub(crate) fn retention_key(request: &Request, hash_fallback: bool) -> Option<RoutingIdentity> {
202    if let Some(identity) = RoutingIdentity::from_request(request) {
203        return Some(identity);
204    }
205    if !hash_fallback {
206        return None;
207    }
208    first_user_message_hash(request).map(|hash| {
209        tracing::debug!(retention_key = %hash, "retaining by message hash fallback");
210        RoutingIdentity::Session(hash)
211    })
212}
213
214/// Hashes the first user message so later turns retain the initial task's affinity.
215/// For benchmarking purpose with harnesses, task instructions are added as a user prompt to the request so we hash the initial user message.
216/// TODO: Have not considered multi-modal payloads yet. That needs to be handled separately.
217fn first_user_message_hash(request: &Request) -> Option<String> {
218    let message = request
219        .llm_request
220        .messages
221        .iter()
222        .find(|message| message.role == Role::User)?;
223    let mut hasher = DefaultHasher::new();
224    message.text_content("")?.hash(&mut hasher);
225    Some(format!("{:016x}", hasher.finish()))
226}
227
228#[async_trait]
229impl<S> Classifier<S> for AffinityRouter
230where
231    S: Send + 'static,
232{
233    async fn score(
234        &self,
235        _state: &mut S,
236        request: &mut Request,
237        driver: &Driver,
238    ) -> crate::Result<(Classification, Option<switchyard_protocol::Response>)> {
239        let Some(key) = self.affinity_key(request) else {
240            return Ok((Classification::Scores(Vec::new()), None));
241        };
242        // Abstaining rather than clearing keeps this read path free of writes, so the judge
243        // decides the turn whatever order concurrent latches land in.
244        if self.release_on_user_turn && has_new_user_turn(&request.llm_request.messages) {
245            return Ok((Classification::Scores(Vec::new()), None));
246        }
247        // An empty `any` group carries no information about which models are still
248        // available, so it must not be read as "every assignment is now stale".
249        let available = driver.models_for(&Category::Any);
250        let mut assignments = self.assignments.lock();
251        let assigned = assignments.get(&key).cloned();
252        let assigned = match assigned.as_ref() {
253            Some((target, _)) if !available.is_empty() && !available.contains(target) => {
254                assignments.remove(&key);
255                None
256            }
257            assigned => assigned,
258        };
259        if assigned.is_some() {
260            driver.set_evidence(serde_json::json!({"source": "retained"}));
261        }
262        Ok((
263            Classification::Scores(match assigned {
264                Some((target, category)) => vec![Score {
265                    confidence: 1.0,
266                    target: target.clone(),
267                    category: category.clone(),
268                }],
269                None => Vec::new(),
270            }),
271            None,
272        ))
273    }
274}
275
276/// Evicts one arbitrary assignment when the map has reached [`MAX_ASSIGNMENTS`].
277pub(crate) fn evict_if_full<V>(retained: &mut HashMap<RoutingIdentity, V>) {
278    if retained.len() >= MAX_ASSIGNMENTS
279        && let Some(evicted) = retained.keys().next().cloned()
280    {
281        retained.remove(&evicted);
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    use std::sync::Arc;
290
291    use switchyard_protocol::{LlmRequest, Metadata, ToolResult, text_request};
292
293    use crate::core::algorithm::RuntimeModels;
294
295    /// Boxed, thread-safe error type keeping the test helpers ergonomic.
296    type BoxErr = Box<dyn std::error::Error + Send + Sync>;
297
298    fn fixed_model(target: &str) -> ModelId {
299        ModelId::from(target)
300    }
301
302    fn driver() -> Driver {
303        Driver::new(
304            "test",
305            Arc::new(RuntimeModels::new(
306                [(
307                    Category::Any,
308                    ["model-a", "model-b", "weak", "strong"]
309                        .map(ModelId::from)
310                        .to_vec(),
311                )]
312                .into(),
313            )),
314        )
315        .0
316    }
317
318    fn request(metadata: Metadata) -> Request {
319        Request {
320            llm_request: text_request(Some("auto".to_string()), "hi"),
321            raw_request: None,
322            metadata: Some(metadata),
323        }
324    }
325
326    fn task_request(
327        metadata: Option<Metadata>,
328        first_user: &str,
329        follow_up: Option<&str>,
330    ) -> Request {
331        let mut messages = vec![
332            Message::text(Role::System, "follow repository instructions"),
333            Message::text(Role::User, first_user),
334        ];
335        if let Some(follow_up) = follow_up {
336            messages.push(Message::text(Role::Assistant, "I will inspect the code."));
337            messages.push(Message::text(Role::User, follow_up));
338        }
339        Request {
340            llm_request: LlmRequest {
341                model: Some("auto".to_string()),
342                messages,
343                ..LlmRequest::default()
344            },
345            raw_request: None,
346            metadata,
347        }
348    }
349
350    fn session(session_id: &str, agent_id: &str) -> Metadata {
351        Metadata {
352            session_id: Some(session_id.to_string()),
353            agent_id: Some(agent_id.to_string()),
354            ..Metadata::default()
355        }
356    }
357
358    fn subagent(agent_id: &str, task_id: &str) -> Metadata {
359        Metadata {
360            session_id: Some("session-1".to_string()),
361            agent_id: Some(agent_id.to_string()),
362            task_id: Some(task_id.to_string()),
363            is_subagent: true,
364            is_delegated_work: true,
365            ..Metadata::default()
366        }
367    }
368
369    /// Folds a request and its decision through the router, retaining `model`.
370    async fn retain(
371        router: &AffinityRouter,
372        state: &mut (),
373        request: &mut Request,
374        model: &'static str,
375    ) -> Result<(), BoxErr> {
376        let selected_model_id = ModelId::from(model);
377        router
378            .process(
379                state,
380                Event::Decision {
381                    request,
382                    selected_model_id: &selected_model_id,
383                    category: None,
384                    driver: &driver(),
385                },
386            )
387            .await?;
388        Ok(())
389    }
390
391    /// Scores through the definitive classification variant used by affinity.
392    async fn scores(
393        classifier: &dyn Classifier,
394        state: &mut (),
395        request: &mut Request,
396    ) -> Result<Vec<Score>, BoxErr> {
397        match classifier.score(state, request, &driver()).await?.0 {
398            Classification::Scores(scores) => Ok(scores),
399            Classification::Ambiguous(_) => Err("affinity never returns ambiguous scores".into()),
400        }
401    }
402
403    #[tokio::test]
404    async fn session_retains_first_model_across_requests() -> Result<(), BoxErr> {
405        let router = AffinityRouter::new();
406        let mut state = ();
407
408        let mut first = request(session("session-1", "agent-a"));
409        retain(&router, &mut state, &mut first, "model-a").await?;
410
411        // A different agent in the same session is scored onto the retained model.
412        let mut second = request(session("session-1", "agent-b"));
413        let scores = scores(&router, &mut state, &mut second).await?;
414        assert_eq!(scores.len(), 1);
415        assert_eq!(scores[0].confidence, 1.0);
416        assert_eq!(scores[0].target, "model-a");
417        Ok(())
418    }
419
420    #[tokio::test]
421    async fn subagent_only_retains_children_without_latching_root_traffic() -> Result<(), BoxErr> {
422        let router = AffinityRouter::for_subagents();
423        let mut state = ();
424
425        let mut root = request(session("session-1", "root-agent"));
426        retain(&router, &mut state, &mut root, "model-a").await?;
427        assert!(scores(&router, &mut state, &mut root).await?.is_empty());
428
429        let mut first_child_turn = request(subagent("child-1", "task-1"));
430        retain(&router, &mut state, &mut first_child_turn, "model-b").await?;
431        let mut later_child_turn = request(subagent("child-1", "task-2"));
432        let scores = scores(&router, &mut state, &mut later_child_turn).await?;
433        assert_eq!(
434            scores.first().map(|score| score.target.as_str()),
435            Some("model-b")
436        );
437        Ok(())
438    }
439
440    #[tokio::test]
441    async fn first_decision_wins() -> Result<(), BoxErr> {
442        let router = AffinityRouter::new();
443        let mut state = ();
444
445        let mut req = request(session("session-1", "agent-a"));
446        retain(&router, &mut state, &mut req, "model-a").await?;
447        // A later decision for the same identity must not overwrite the first.
448        retain(&router, &mut state, &mut req, "model-b").await?;
449
450        let scores = scores(&router, &mut state, &mut req).await?;
451        assert_eq!(
452            scores.first().map(|score| score.target.as_str()),
453            Some("model-a")
454        );
455        Ok(())
456    }
457
458    #[tokio::test]
459    async fn subagent_is_keyed_by_agent_not_task() -> Result<(), BoxErr> {
460        let router = AffinityRouter::new();
461        let mut state = ();
462
463        let mut first = request(subagent("child-1", "task-1"));
464        retain(&router, &mut state, &mut first, "model-a").await?;
465
466        // Same child, different task: still scored onto the retained model.
467        let mut second = request(subagent("child-1", "task-2"));
468        let scores = scores(&router, &mut state, &mut second).await?;
469        assert_eq!(
470            scores.first().map(|score| score.target.as_str()),
471            Some("model-a")
472        );
473        Ok(())
474    }
475
476    #[tokio::test]
477    async fn distinct_subagents_are_assigned_independently() -> Result<(), BoxErr> {
478        let router = AffinityRouter::new();
479        let mut state = ();
480
481        // One child in the session is pinned...
482        retain(
483            &router,
484            &mut state,
485            &mut request(subagent("child-1", "task-1")),
486            "model-a",
487        )
488        .await?;
489
490        // ...a sibling child in the same session has no assignment of its own yet.
491        let mut sibling = request(subagent("child-2", "task-1"));
492        assert!(scores(&router, &mut state, &mut sibling).await?.is_empty());
493        Ok(())
494    }
495
496    #[tokio::test]
497    async fn subagent_does_not_inherit_session_assignment() -> Result<(), BoxErr> {
498        let router = AffinityRouter::new();
499        let mut state = ();
500
501        // The session root is pinned, but a sub-agent is keyed separately...
502        retain(
503            &router,
504            &mut state,
505            &mut request(session("session-1", "root-1")),
506            "model-a",
507        )
508        .await?;
509
510        // ...so the sub-agent abstains until it is assigned in its own right.
511        let mut child = request(subagent("child-1", "task-1"));
512        assert!(scores(&router, &mut state, &mut child).await?.is_empty());
513        Ok(())
514    }
515
516    #[tokio::test]
517    async fn classifier_abstains_without_a_session() -> Result<(), BoxErr> {
518        let router = AffinityRouter::new();
519        let mut state = ();
520
521        // No session id at all: nothing to key on.
522        let mut req = request(Metadata::default());
523        assert!(scores(&router, &mut state, &mut req).await?.is_empty());
524        Ok(())
525    }
526
527    #[tokio::test]
528    async fn message_hash_fallback_uses_the_first_user_message() -> Result<(), BoxErr> {
529        let router = AffinityRouter::new().with_message_hash_fallback();
530        let mut state = ();
531
532        let mut first = task_request(
533            None,
534            "Add a unit test for this function.",
535            Some("Now run the test suite."),
536        );
537        retain(&router, &mut state, &mut first, "weak").await?;
538
539        let mut follow_up = task_request(
540            None,
541            "Add a unit test for this function.",
542            Some("Now file a pull request."),
543        );
544        assert_eq!(
545            scores(&router, &mut state, &mut follow_up)
546                .await?
547                .first()
548                .map(|score| score.target.as_str()),
549            Some("weak")
550        );
551
552        let mut other_task = task_request(
553            None,
554            "Reimplement this binary from two input/output pairs.",
555            Some("Now run the test suite."),
556        );
557        assert!(
558            scores(&router, &mut state, &mut other_task)
559                .await?
560                .is_empty()
561        );
562        Ok(())
563    }
564
565    #[tokio::test]
566    async fn subagents_only_root_traffic_does_not_warn() -> Result<(), BoxErr> {
567        // Abstaining on root traffic is this mode's contract, so it must not warn.
568        let router = AffinityRouter::for_subagents();
569        let mut state = ();
570
571        let mut root = request(session("session-1", "agent-1"));
572        assert!(scores(&router, &mut state, &mut root).await?.is_empty());
573        assert!(
574            router.should_warn_unkeyed(),
575            "an intentional abstention should leave the warning unconsumed"
576        );
577        Ok(())
578    }
579
580    #[test]
581    fn user_message_hash_ignores_non_text_provider_payloads() {
582        let request = |user_message| Request {
583            llm_request: LlmRequest {
584                messages: vec![user_message],
585                ..LlmRequest::default()
586            },
587            raw_request: None,
588            metadata: None,
589        };
590        let text_only = request(Message::text(Role::User, "Implement the parser."));
591        let text_with_reasoning = request(Message {
592            role: Role::User,
593            content: vec![
594                ContentBlock::Text {
595                    text: "Implement the parser.".to_string(),
596                },
597                ContentBlock::Reasoning {
598                    text: "Internal provider reasoning.".to_string(),
599                    signature: Some("provider-signature".to_string()),
600                    details: Vec::new(),
601                },
602            ],
603        });
604
605        assert_eq!(
606            first_user_message_hash(&text_only),
607            first_user_message_hash(&text_with_reasoning)
608        );
609    }
610
611    #[tokio::test]
612    async fn metadata_session_takes_precedence_over_message_hash() -> Result<(), BoxErr> {
613        let router = AffinityRouter::new().with_message_hash_fallback();
614        let mut state = ();
615
616        let mut first = task_request(
617            Some(session("session-1", "agent-a")),
618            "Implement the parser.",
619            None,
620        );
621        retain(&router, &mut state, &mut first, "strong").await?;
622
623        let mut other_session = task_request(
624            Some(session("session-2", "agent-a")),
625            "Implement the parser.",
626            None,
627        );
628        assert!(
629            scores(&router, &mut state, &mut other_session)
630                .await?
631                .is_empty()
632        );
633        Ok(())
634    }
635
636    #[tokio::test]
637    async fn subagent_without_a_session_abstains_and_warns() -> Result<(), BoxErr> {
638        for session_id in [None, Some(String::new())] {
639            let router = AffinityRouter::new().with_message_hash_fallback();
640            let mut state = ();
641            let mut subagent = task_request(
642                Some(Metadata {
643                    session_id,
644                    agent_id: Some("agent-1".to_string()),
645                    is_subagent: true,
646                    ..Metadata::default()
647                }),
648                "Implement the parser.",
649                None,
650            );
651
652            retain(&router, &mut state, &mut subagent, "model-a").await?;
653            assert!(scores(&router, &mut state, &mut subagent).await?.is_empty());
654            assert!(
655                !router.should_warn_unkeyed(),
656                "an unidentifiable subagent should consume the warning"
657            );
658        }
659        Ok(())
660    }
661
662    #[tokio::test]
663    async fn one_router_serves_both_roles() -> Result<(), BoxErr> {
664        // The same instance is registered under both SDK roles; a decision folded in via
665        // the processor handle is read back via the classifier handle.
666        let router = Arc::new(AffinityRouter::new());
667        let processor: Arc<dyn Processor> = router.clone();
668        let classifier: Arc<dyn Classifier> = router;
669        let mut state = ();
670
671        let mut first = request(session("session-1", "agent-a"));
672        processor
673            .process(
674                &mut state,
675                Event::Decision {
676                    request: &mut first,
677                    selected_model_id: &fixed_model("model-a"),
678                    category: None,
679                    driver: &driver(),
680                },
681            )
682            .await?;
683
684        let mut second = request(session("session-1", "agent-b"));
685        let scores = scores(classifier.as_ref(), &mut state, &mut second).await?;
686        assert_eq!(
687            scores.first().map(|score| score.target.as_str()),
688            Some("model-a")
689        );
690        Ok(())
691    }
692
693    #[tokio::test]
694    async fn decision_without_an_affinity_identity_is_ignored() -> Result<(), BoxErr> {
695        let router = AffinityRouter::new();
696        let mut state = ();
697        let mut unkeyed = request(Metadata::default());
698
699        router
700            .process(
701                &mut state,
702                Event::Decision {
703                    request: &mut unkeyed,
704                    selected_model_id: &fixed_model("model-a"),
705                    category: None,
706                    driver: &driver(),
707                },
708            )
709            .await?;
710
711        let mut req = request(session("session-1", "agent-a"));
712        assert!(scores(&router, &mut state, &mut req).await?.is_empty());
713        Ok(())
714    }
715
716    #[tokio::test]
717    async fn decisions_retain_their_originating_request_identity() -> Result<(), BoxErr> {
718        let router = AffinityRouter::new();
719        let mut state = ();
720        let mut first = request(session("session-1", "agent-a"));
721        let mut second = request(session("session-2", "agent-b"));
722
723        // Replay decisions in the opposite order. Each decision carries its request,
724        // so the assignments cannot cross.
725        router
726            .process(
727                &mut state,
728                Event::Decision {
729                    request: &mut second,
730                    selected_model_id: &fixed_model("model-b"),
731                    category: None,
732                    driver: &driver(),
733                },
734            )
735            .await?;
736        router
737            .process(
738                &mut state,
739                Event::Decision {
740                    request: &mut first,
741                    selected_model_id: &fixed_model("model-a"),
742                    category: None,
743                    driver: &driver(),
744                },
745            )
746            .await?;
747
748        let first_scores = scores(&router, &mut state, &mut first).await?;
749        let second_scores = scores(&router, &mut state, &mut second).await?;
750        assert_eq!(
751            first_scores.first().map(|score| score.target.as_str()),
752            Some("model-a")
753        );
754        assert_eq!(
755            second_scores.first().map(|score| score.target.as_str()),
756            Some("model-b")
757        );
758        Ok(())
759    }
760
761    #[tokio::test]
762    async fn distinct_sessions_are_assigned_independently() -> Result<(), BoxErr> {
763        let router = AffinityRouter::new();
764        let mut state = ();
765
766        retain(
767            &router,
768            &mut state,
769            &mut request(session("session-1", "agent-a")),
770            "model-a",
771        )
772        .await?;
773        retain(
774            &router,
775            &mut state,
776            &mut request(session("session-2", "agent-a")),
777            "model-b",
778        )
779        .await?;
780
781        let first = scores(
782            &router,
783            &mut state,
784            &mut request(session("session-1", "other")),
785        )
786        .await?;
787        let second = scores(
788            &router,
789            &mut state,
790            &mut request(session("session-2", "other")),
791        )
792        .await?;
793        assert_eq!(
794            first.first().map(|score| score.target.as_str()),
795            Some("model-a")
796        );
797        assert_eq!(
798            second.first().map(|score| score.target.as_str()),
799            Some("model-b")
800        );
801        Ok(())
802    }
803
804    #[tokio::test]
805    async fn subagent_without_an_agent_id_abstains_and_warns() -> Result<(), BoxErr> {
806        let router = AffinityRouter::new();
807        let mut state = ();
808
809        // The sub-agent flag is set but no agent id is present, so no key can be formed;
810        // the request is neither retained nor scored.
811        let metadata = Metadata {
812            session_id: Some("session-1".to_string()),
813            is_subagent: true,
814            ..Metadata::default()
815        };
816        let mut req = request(metadata);
817        retain(&router, &mut state, &mut req, "model-a").await?;
818        assert!(scores(&router, &mut state, &mut req).await?.is_empty());
819        assert!(
820            !router.should_warn_unkeyed(),
821            "an unidentifiable subagent should consume the warning"
822        );
823        Ok(())
824    }
825
826    #[tokio::test]
827    async fn assignments_are_bounded_by_the_cap() -> Result<(), BoxErr> {
828        let router = AffinityRouter::new();
829        let mut state = ();
830
831        // One distinct session past the cap forces exactly one eviction.
832        for index in 0..=MAX_ASSIGNMENTS {
833            let session_id = format!("session-{index}");
834            retain(
835                &router,
836                &mut state,
837                &mut request(session(&session_id, "agent-a")),
838                "model-a",
839            )
840            .await?;
841        }
842
843        let len = router.assignments.lock().len();
844        assert_eq!(len, MAX_ASSIGNMENTS);
845        Ok(())
846    }
847
848    #[tokio::test]
849    async fn release_on_user_turn_drops_the_assignment_only_when_the_user_speaks()
850    -> Result<(), BoxErr> {
851        let router = AffinityRouter::new().with_release_on_user_turn();
852        let mut state = ();
853        let mut opening = task_request(Some(session("session-1", "agent-a")), "add caching", None);
854        retain(&router, &mut state, &mut opening, "weak").await?;
855
856        // A tool continuation holds the assignment, so no judge call.
857        let mut continued = opening.clone();
858        continued.llm_request.messages.push(Message {
859            role: Role::User,
860            content: vec![ContentBlock::ToolResult(ToolResult {
861                tool_call_id: "call-1".to_string(),
862                content: Vec::new(),
863                is_error: None,
864            })],
865        });
866        assert_eq!(
867            scores(&router, &mut state, &mut continued)
868                .await?
869                .first()
870                .map(|s| s.target.as_str()),
871            Some("weak")
872        );
873
874        // A new user message releases it, so the turn abstains and the judge runs.
875        let mut spoke = task_request(
876            Some(session("session-1", "agent-a")),
877            "add caching",
878            Some("no, shared across processes"),
879        );
880        assert!(scores(&router, &mut state, &mut spoke).await?.is_empty());
881        Ok(())
882    }
883
884    #[tokio::test]
885    async fn latch_only_retains_matching_models() -> Result<(), BoxErr> {
886        let router = AffinityRouter::new().with_latch_only(["strong"]);
887        let mut state = ();
888        let mut req = request(session("session-1", "agent-a"));
889
890        // A "weak" decision is not retained — a later turn is not latched.
891        retain(&router, &mut state, &mut req, "weak").await?;
892        assert!(scores(&router, &mut state, &mut req).await?.is_empty());
893
894        // A "strong" decision is retained — later turns latch onto it.
895        retain(&router, &mut state, &mut req, "strong").await?;
896        assert_eq!(
897            scores(&router, &mut state, &mut req)
898                .await?
899                .first()
900                .map(|s| s.target.as_str()),
901            Some("strong")
902        );
903        Ok(())
904    }
905
906    #[tokio::test]
907    async fn an_unprovisioned_any_group_does_not_evict_assignments() -> Result<(), BoxErr> {
908        let router = AffinityRouter::new();
909        let mut state = ();
910        let mut req = request(session("session-1", "agent-a"));
911        retain(&router, &mut state, &mut req, "model-a").await?;
912
913        let empty = Driver::new("test", Arc::new(RuntimeModels::default())).0;
914        let (classification, _) = router.score(&mut state, &mut req, &empty).await?;
915        let Classification::Scores(retained) = classification else {
916            return Err("affinity never returns ambiguous scores".into());
917        };
918        assert_eq!(retained.first().map(|s| s.target.as_str()), Some("model-a"));
919
920        // The assignment survived, so a later turn with the group present still latches.
921        assert_eq!(
922            scores(&router, &mut state, &mut req)
923                .await?
924                .first()
925                .map(|s| s.target.as_str()),
926            Some("model-a")
927        );
928        Ok(())
929    }
930}