Skip to main content

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}