Skip to main content

switchyard_libsy/algorithms/util/
subagent.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Sub-agent override as a single SDK component.
5//!
6//! [`SubagentOverride`] scores one fixed worker target for requests carrying delegated
7//! sub-agent work ([`Metadata::is_subagent_work`]) and abstains for everything else, so a
8//! cascade falls through to its later classifiers on ordinary traffic.
9//!
10//! It is stateless and holds only the worker's *name*: the fall-through cascade resolves it
11//! against its
12//! target set. Keeping the policy independent of any memory of past decisions is what lets
13//! it compose with a stateful classifier such as
14//! [`AffinityRouter`](crate::algorithms::AffinityRouter) — the override decides *which*
15//! target delegated work belongs on, affinity decides *how long* a decision lives, and
16//! neither needs to know about the other.
17
18use async_trait::async_trait;
19
20use crate::Result;
21use crate::core::algorithm::Driver;
22use crate::core::classifier::{Classification, Classifier, Score};
23use switchyard_protocol::{Metadata, Request, Response};
24
25/// Scores a fixed worker target for delegated sub-agent work; abstains otherwise.
26pub struct SubagentOverride {
27    /// Name of the worker target, resolved by the cascade against its target set.
28    worker: String,
29}
30
31impl SubagentOverride {
32    /// Creates an override scoring `worker` for delegated sub-agent work.
33    ///
34    /// `worker` must name a target in the cascade's set, or routing a sub-agent request
35    /// fails with [`LibsyError::TargetNotFound`](crate::LibsyError::TargetNotFound).
36    pub fn new(worker: impl Into<String>) -> Self {
37        Self {
38            worker: worker.into(),
39        }
40    }
41}
42
43#[async_trait]
44impl<S> Classifier<S> for SubagentOverride
45where
46    S: Send + 'static,
47{
48    async fn score(
49        &self,
50        _state: &mut S,
51        request: &mut Request,
52        _driver: Option<&Driver>,
53    ) -> Result<(Classification, Option<Response>)> {
54        // Delegated *work* only. A harness maintenance turn (e.g. Codex `compact`) carries
55        // sub-agent lineage but is not delegated work, so it abstains and routes normally.
56        let is_delegated_work = request
57            .metadata
58            .as_ref()
59            .is_some_and(Metadata::is_subagent_work);
60        Ok((
61            Classification::Scores(if is_delegated_work {
62                vec![Score {
63                    confidence: 1.0,
64                    target: self.worker.clone(),
65                }]
66            } else {
67                Vec::new()
68            }),
69            None,
70        ))
71    }
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77    use crate::text::text_request;
78
79    fn metadata_from_headers(headers: &[(&str, &str)]) -> Option<Metadata> {
80        if headers.is_empty() {
81            return None;
82        }
83        let mut metadata = Metadata::default();
84        for (name, value) in headers {
85            match *name {
86                "x-claude-code-session-id" => metadata.session_id = Some((*value).to_string()),
87                "x-claude-code-agent-id" => {
88                    metadata.agent_id = Some((*value).to_string());
89                    metadata.is_subagent = true;
90                    metadata.is_delegated_work = true;
91                }
92                "x-openai-subagent" => {
93                    metadata.agent_kind = Some((*value).to_string());
94                    metadata.is_subagent = true;
95                    metadata.is_delegated_work = matches!(*value, "collab_spawn" | "review");
96                }
97                "x-switchyard-is-subagent" if *value == "false" => {
98                    metadata.is_subagent = false;
99                    metadata.is_delegated_work = false;
100                }
101                _ => {}
102            }
103        }
104        Some(metadata)
105    }
106
107    fn request(headers: &[(&str, &str)]) -> Request {
108        Request {
109            llm_request: text_request(Some("auto".to_string()), "hi"),
110            raw_request: None,
111            metadata: metadata_from_headers(headers),
112        }
113    }
114
115    /// Scores `headers` through the override, returning the winning target if it scored.
116    async fn selected(headers: &[(&str, &str)]) -> Result<Option<String>> {
117        let mut state = ();
118        let classification = SubagentOverride::new("worker")
119            .score(&mut state, &mut request(headers), None)
120            .await?;
121        Ok(classification.0.argmax(false)?.map(|score| score.target))
122    }
123
124    #[tokio::test]
125    async fn requests_without_metadata_abstain() -> Result<()> {
126        assert_eq!(selected(&[]).await?, None);
127        Ok(())
128    }
129
130    #[tokio::test]
131    async fn subagent_work_scores_the_worker() -> Result<()> {
132        // Claude Code child-agent lineage.
133        let claude = &[
134            ("x-claude-code-session-id", "root"),
135            ("x-claude-code-agent-id", "child-1"),
136        ];
137        assert_eq!(selected(claude).await?, Some("worker".to_string()));
138
139        // Codex delegated-work kinds.
140        assert_eq!(
141            selected(&[("x-openai-subagent", "review")]).await?,
142            Some("worker".to_string())
143        );
144        assert_eq!(
145            selected(&[("x-openai-subagent", "collab_spawn")]).await?,
146            Some("worker".to_string())
147        );
148        Ok(())
149    }
150
151    #[tokio::test]
152    async fn harness_maintenance_turns_abstain() -> Result<()> {
153        assert_eq!(selected(&[("x-openai-subagent", "compact")]).await?, None);
154        assert_eq!(
155            selected(&[("x-switchyard-is-subagent", "false")]).await?,
156            None
157        );
158        Ok(())
159    }
160
161    #[tokio::test]
162    async fn delegated_work_is_scored_definitively() -> Result<()> {
163        // Confidence 1.0 under `Scores` (never `Ambiguous`), so the cascade stops here
164        // rather than consulting later classifiers.
165        let mut state = ();
166        let classification = SubagentOverride::new("worker")
167            .score(
168                &mut state,
169                &mut request(&[("x-openai-subagent", "review")]),
170                None,
171            )
172            .await?;
173        match classification.0 {
174            Classification::Scores(scores) => {
175                assert_eq!(scores.len(), 1);
176                assert_eq!(scores[0].confidence, 1.0);
177            }
178            Classification::Ambiguous(_) => panic!("override must score definitively"),
179        }
180        Ok(())
181    }
182}