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