switchyard_libsy/algorithms/util/
subagent.rs1use 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
25pub struct SubagentOverride {
27 worker: String,
29}
30
31impl SubagentOverride {
32 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 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 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 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 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 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}