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