lc_agents/orchestrator/supervisor.rs
1//! N1 (v0.24.0): Supervisor dynamic-routing orchestrator — "agent as tool",
2//! one-level sub-agent recursion.
3//!
4//! [`SequentialPipeline`](super::SequentialPipeline) runs a *fixed* stage list and
5//! [`FanOutFanIn`](super::FanOutFanIn) *broadcasts* the same task to every worker;
6//! the [`Supervisor`] instead decides **at runtime** which named worker (a
7//! sub-agent) to hand the current sub-task to — or whether to finish — by asking a
8//! supervisor model once per round. Each worker runs a complete, independent
9//! lifecycle behind the [`Orchestrator`] trait (its own executor / budget / cost /
10//! interrupt / checkpoint): the compositor only builds the [`AgentTask`] and
11//! collects the output. That output is fed **back** into the supervisor's
12//! scratchpad, so the routing decision on the next round can depend on what prior
13//! workers produced.
14//!
15//! This is exactly one level of sub-agent recursion: workers are leaf
16//! orchestrators. The routing decision the supervisor model returns is parsed by
17//! [`parse_supervisor_decision`] (JSON first, delimiters as a fallback, mirroring
18//! [`super::parse_review_verdict`]).
19
20use async_trait::async_trait;
21use serde_json::{json, Value};
22use std::sync::Arc;
23
24use super::{Orchestrator, RunContext};
25use crate::task::AgentTask;
26use crate::AgentError;
27
28/// Reserved worker name meaning "delegation is done; return the answer".
29/// A real worker must not be registered under this name (case-insensitive).
30pub const SUPERVISOR_FINISH: &str = "FINISH";
31
32/// One routing decision the supervisor reaches each round.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum SupervisorNext {
35 /// Delegate a subtask to the named worker (sub-agent), then route again with
36 /// the worker's output fed back into the scratchpad.
37 Work {
38 /// Worker to delegate to (must be one of the registered names).
39 worker: String,
40 /// The subtask objective handed to that worker.
41 task: String,
42 },
43 /// Stop delegating and return `answer` to the caller.
44 Finish {
45 /// The final answer.
46 answer: String,
47 },
48}
49
50/// Returns the text between two markers (trimmed), or `None` if a marker is missing.
51fn between<'a>(text: &'a str, start: &str, end: &str) -> Option<&'a str> {
52 let s = text.find(start)?;
53 let rest = &text[s + start.len()..];
54 let e = rest.find(end)?;
55 Some(rest[..e].trim())
56}
57
58fn json_str<'a>(v: &'a Value, key: &str) -> &'a str {
59 v.get(key).and_then(Value::as_str).unwrap_or("")
60}
61
62/// Classifies a parsed `next` token plus its task/answer strings.
63fn classify(next: &str, task: &str, answer: &str) -> SupervisorNext {
64 if next.eq_ignore_ascii_case(SUPERVISOR_FINISH) {
65 SupervisorNext::Finish {
66 answer: answer.to_string(),
67 }
68 } else {
69 SupervisorNext::Work {
70 worker: next.to_string(),
71 task: task.to_string(),
72 }
73 }
74}
75
76/// Parses the supervisor model's conclusion into a [`SupervisorNext`].
77///
78/// Accepted formats (the model output is trimmed first):
79/// 1. JSON delegate: `{"next": "<worker>", "task": "<subtask>"}`.
80/// 2. JSON finish: `{"next": "FINISH", "answer": "<final answer>"}`.
81/// 3. Delimited: `<<<NEXT>>>name<<<END_NEXT>>>` together with optional
82/// `<<<TASK>>>...<<<END_TASK>>>` (delegation) or
83/// `<<<ANSWER>>>...<<<END_ANSWER>>>` (finish).
84///
85/// Returns `None` when no recognizable decision is present.
86pub fn parse_supervisor_decision(text: &str) -> Option<SupervisorNext> {
87 let text = text.trim();
88
89 // 1. JSON
90 if let Ok(value) = serde_json::from_str::<Value>(text) {
91 if let Some(next) = value.get("next").and_then(Value::as_str) {
92 return Some(classify(
93 next.trim(),
94 json_str(&value, "task"),
95 json_str(&value, "answer"),
96 ));
97 }
98 }
99
100 // 2. Delimiters
101 let next = between(text, "<<<NEXT>>>", "<<<END_NEXT>>>")?;
102 let task = between(text, "<<<TASK>>>", "<<<END_TASK>>>").unwrap_or("");
103 let answer = between(text, "<<<ANSWER>>>", "<<<END_ANSWER>>>").unwrap_or(task);
104 Some(classify(next, task, answer))
105}
106
107/// Builds the supervisor model's input envelope: the original objective, the
108/// workers it may delegate to, the current round, and every worker result so far.
109/// The history is what makes routing *dynamic* — the model chooses the next worker
110/// from what earlier workers returned rather than from a fixed schedule.
111pub fn supervisor_envelope(
112 objective: &str,
113 worker_names: &[String],
114 round: usize,
115 history: &[(String, String)],
116) -> String {
117 let results: Vec<Value> = history
118 .iter()
119 .map(|(worker, output)| json!({ "worker": worker, "output": output }))
120 .collect();
121 json!({
122 "objective": objective,
123 "workers": worker_names,
124 "round": round,
125 "results": results,
126 "instruction": format!(
127 "Delegate to one worker via {{\"next\":\"<worker>\",\"task\":\"<subtask>\"}}, \
128 or finish via {{\"next\":\"{SUPERVISOR_FINISH}\",\"answer\":\"<final answer>\"}}."
129 ),
130 })
131 .to_string()
132}
133
134type Worker = Arc<dyn Orchestrator<Input = AgentTask, Output = String>>;
135
136/// Supervisor dynamic-routing orchestrator (N1).
137///
138/// Each round it asks `supervisor_llm` (a `String -> String` [`Orchestrator`],
139/// typically an LLM with a routing prompt) where to send the current sub-task,
140/// parses the decision, and either returns the final answer or invokes the chosen
141/// worker as a sub-agent. Worker outputs accumulate in the scratchpad and are
142/// re-presented to the model on the next round. Delegation is bounded by
143/// `max_rounds`, which is the one-level recursion guard: the run errors rather
144/// than looping forever if the model never reaches [`SUPERVISOR_FINISH`].
145pub struct Supervisor {
146 workers: Vec<(String, Worker)>,
147 supervisor_llm: Arc<dyn Orchestrator<Input = String, Output = String>>,
148 max_rounds: usize,
149}
150
151impl Supervisor {
152 /// Build a supervisor.
153 ///
154 /// # Arguments
155 /// * `supervisor_llm` — the router (takes [`supervisor_envelope`], returns a
156 /// decision parseable by [`parse_supervisor_decision`]).
157 /// * `workers` — `(name, sub-agent)` pairs in delegation/listing order; names
158 /// must be unique and must not be [`SUPERVISOR_FINISH`].
159 /// * `max_rounds` — maximum delegation rounds (at least 1).
160 pub fn new(
161 supervisor_llm: Arc<dyn Orchestrator<Input = String, Output = String>>,
162 workers: Vec<(String, Worker)>,
163 max_rounds: usize,
164 ) -> Self {
165 Self {
166 workers,
167 supervisor_llm,
168 max_rounds: max_rounds.max(1),
169 }
170 }
171
172 /// Adjust the maximum delegation rounds (at least 1).
173 pub fn with_max_rounds(mut self, max_rounds: usize) -> Self {
174 self.max_rounds = max_rounds.max(1);
175 self
176 }
177
178 /// The maximum delegation rounds.
179 pub fn max_rounds(&self) -> usize {
180 self.max_rounds
181 }
182
183 /// Registered worker names in registration order.
184 pub fn worker_names(&self) -> Vec<&str> {
185 self.workers.iter().map(|(n, _)| n.as_str()).collect()
186 }
187
188 /// Registered worker names as owned strings (envelope/error friendly).
189 fn names(&self) -> Vec<String> {
190 self.workers.iter().map(|(n, _)| n.clone()).collect()
191 }
192}
193
194#[async_trait]
195impl Orchestrator for Supervisor {
196 type Input = AgentTask;
197 type Output = String;
198
199 async fn run_with_context(
200 &self,
201 input: Self::Input,
202 ctx: &RunContext,
203 ) -> Result<Self::Output, AgentError> {
204 if self.workers.is_empty() {
205 return Err(AgentError::Other(
206 "Supervisor requires at least one worker".to_string(),
207 ));
208 }
209
210 let names = self.names();
211 let objective = input.objective.clone();
212 // (worker, output) pairs accumulated in delegation order — the feedback
213 // scratchpad the next routing decision is based on.
214 let mut history: Vec<(String, String)> = Vec::new();
215
216 for round in 0..self.max_rounds {
217 log::debug!(
218 target: "lc_agents::orchestrator",
219 "Supervisor round {}/{} workers={} trace_id={}",
220 round + 1,
221 self.max_rounds,
222 names.len(),
223 ctx.trace_id
224 );
225
226 let envelope = supervisor_envelope(&objective, &names, round, &history);
227 let decision_text = self
228 .supervisor_llm
229 .run_with_context(envelope, ctx)
230 .await
231 .map_err(|e| {
232 AgentError::Other(format!("Supervisor router (round {round}): {e}"))
233 })?;
234 let decision = parse_supervisor_decision(&decision_text).ok_or_else(|| {
235 AgentError::Other(format!(
236 "Supervisor: unparseable routing decision (round {round}): {decision_text}"
237 ))
238 })?;
239
240 match decision {
241 SupervisorNext::Finish { answer } => {
242 log::debug!(
243 target: "lc_agents::orchestrator",
244 "Supervisor finished on round {}",
245 round + 1
246 );
247 return Ok(answer);
248 }
249 SupervisorNext::Work { worker, task } => {
250 let (_, worker_orch) = self
251 .workers
252 .iter()
253 .find(|(name, _)| name == &worker)
254 .ok_or_else(|| {
255 AgentError::Other(format!(
256 "Supervisor: unknown worker '{worker}' (round {round}); available: {names:?}"
257 ))
258 })?;
259
260 // Each delegation is a fresh sub-agent task; the parent task's
261 // constraints (expected output / tool allowlist) propagate so
262 // the sub-agent stays inside the same contract.
263 let mut sub_task = AgentTask::new(task);
264 if let Some(expected) = &input.expected_output {
265 sub_task = sub_task.with_expected_output(expected.clone());
266 }
267 sub_task = sub_task.with_allowed_tools(input.allowed_tools.clone());
268
269 // The worker runs its own full lifecycle here; only its text
270 // output is folded back into the supervisor scratchpad.
271 let output =
272 worker_orch
273 .run_with_context(sub_task, ctx)
274 .await
275 .map_err(|e| {
276 AgentError::Other(format!(
277 "Supervisor worker '{worker}' (round {round}): {e}"
278 ))
279 })?;
280 history.push((worker, output));
281 }
282 }
283 }
284
285 Err(AgentError::Other(format!(
286 "Supervisor: did not reach {SUPERVISOR_FINISH} within {} delegation round(s)",
287 self.max_rounds
288 )))
289 }
290}