Skip to main content

entelix_agents/
supervisor.rs

1//! `create_supervisor_agent` — supervisor routes each turn to one of N
2//! pre-built sub-agents (or finishes the conversation). The supervisor
3//! itself is a `Runnable<Vec<Message>, SupervisorDecision>` — the
4//! typed enum forecloses LLM hallucination of unregistered agent
5//! names: a router that returns `Agent("rsearch")` when the registry
6//! holds `"research"` would now have to hallucinate a *type* (which
7//! is impossible) rather than just a string (which is easy).
8//!
9//! ## Routing contract
10//!
11//! Each turn:
12//! 1. `router.invoke(messages, ctx)` → [`SupervisorDecision`].
13//! 2. `Finish` ends the conversation; `Agent(name)` runs the named
14//!    sub-agent and loops back to the supervisor.
15//! 3. An `Agent(name)` whose name is not in the registry surfaces
16//!    `Error::Config` immediately — the validator runs at graph
17//!    compile time so the failure is observed as a configuration
18//!    bug rather than a runtime "unknown route" deadlock.
19
20use std::sync::Arc;
21
22use entelix_core::ir::Message;
23use entelix_core::{Error, ExecutionContext, Result};
24use entelix_graph::StateGraph;
25
26use crate::agent::Agent;
27use entelix_runnable::{Runnable, RunnableLambda};
28
29use crate::state::SupervisorState;
30
31/// Decision a supervisor router emits each turn.
32///
33/// Replaces the prior `String` + `SUPERVISOR_FINISH` sentinel
34/// pairing — invariant #17 (typed signal beats stringly-typed
35/// sentinel). LLM-driven routers parse their own text into this
36/// enum at the boundary; routers backed by deterministic logic
37/// match against `state.messages` directly.
38#[derive(Clone, Debug, Eq, PartialEq, Hash)]
39#[non_exhaustive]
40pub enum SupervisorDecision {
41    /// Route the next turn to the named sub-agent. The name MUST
42    /// match an `AgentEntry::name` registered with the supervisor;
43    /// otherwise the run surfaces `Error::Config` rather than
44    /// silently dead-ending.
45    Agent(String),
46    /// Terminate the conversation. The supervisor returns the
47    /// final `SupervisorState` to its caller.
48    Finish,
49    /// Route the next turn to the named sub-agent **and** inject a
50    /// typed `payload` (research summary, classification result,
51    /// escalation reason, …) as a `system` message visible to the
52    /// receiving agent. Same routing rules as
53    /// [`Self::Agent`] — unknown names trip a `tracing::warn!` and
54    /// finish the run rather than dead-ending. Mirrors OpenAI
55    /// Agents' `Handoff(input_type, on_handoff)` pattern: the
56    /// supervisor speaks structured context to the next agent
57    /// without round-tripping through the model's natural-language
58    /// channel.
59    Handoff {
60        /// Registered `AgentEntry::name`.
61        agent: String,
62        /// JSON payload injected as the next agent's leading
63        /// `system` message. Operators are responsible for
64        /// keeping payloads model-safe (the channel is LLM-facing
65        /// per invariant 16).
66        payload: serde_json::Value,
67    },
68}
69
70impl SupervisorDecision {
71    /// Convenience constructor for the common case.
72    pub fn agent(name: impl Into<String>) -> Self {
73        Self::Agent(name.into())
74    }
75
76    /// Convenience constructor for typed handoff with a payload.
77    ///
78    /// Reach for this when the supervisor's routing logic produces
79    /// structured context the next agent should see — research
80    /// summary, classification verdict, escalation reason — and
81    /// returning `Agent(name)` would force that context to round-
82    /// trip through the model's natural-language channel. The
83    /// dispatch loop injects `payload` as the receiving agent's
84    /// leading `system` message; the operator owns model-safety of
85    /// the payload contents (invariant 16).
86    pub fn handoff(agent: impl Into<String>, payload: serde_json::Value) -> Self {
87        Self::Handoff {
88            agent: agent.into(),
89            payload,
90        }
91    }
92
93    /// Name of the receiving agent, or `None` for [`Self::Finish`].
94    /// Both [`Self::Agent`] and [`Self::Handoff`] route to a
95    /// registered `AgentEntry::name`.
96    #[must_use]
97    pub fn agent_name(&self) -> Option<&str> {
98        match self {
99            Self::Agent(name) => Some(name),
100            Self::Handoff { agent, .. } => Some(agent),
101            Self::Finish => None,
102        }
103    }
104}
105
106/// One named sub-agent in a [`create_supervisor_agent`] graph.
107///
108/// The agent itself is any `Runnable<Vec<Message>, Message>` — for
109/// example another `CompiledGraph` from [`crate::create_chat_agent`] or
110/// [`crate::create_react_agent`] composed inline, or a bare
111/// `ChatModel`.
112pub struct AgentEntry {
113    /// Stable identifier the supervisor uses to route. Must be unique
114    /// within a supervisor graph.
115    pub name: String,
116    /// The agent itself, type-erased to a chat-shaped runnable.
117    pub agent: Arc<dyn Runnable<Vec<Message>, Message>>,
118}
119
120impl AgentEntry {
121    /// Convenience constructor.
122    pub fn new<R>(name: impl Into<String>, agent: R) -> Self
123    where
124        R: Runnable<Vec<Message>, Message> + 'static,
125    {
126        Self {
127            name: name.into(),
128            agent: Arc::new(agent),
129        }
130    }
131}
132
133/// Build a supervisor graph that picks among `agents` each turn.
134///
135/// Builds the supervisor graph (router ↔ agents ↔ finish) without
136/// wrapping it into an [`Agent`]. Use this when you want to
137/// configure the agent surface (name, sink, approver, observers)
138/// via [`Agent::builder`] directly.
139///
140/// Validation: every name registered with the router must be in the
141/// `agents` list — a router that emits `Agent("research")` while the
142/// registry only knows `"research-team"` surfaces `Error::Config`
143/// at the moment a turn picks the unknown name (the conditional
144/// edge cannot route to a non-existent node).
145pub fn build_supervisor_graph<R>(
146    router: R,
147    agents: Vec<AgentEntry>,
148) -> Result<entelix_graph::CompiledGraph<SupervisorState>>
149where
150    R: Runnable<Vec<Message>, SupervisorDecision> + 'static,
151{
152    if agents.is_empty() {
153        return Err(Error::config(
154            "build_supervisor_graph: at least one agent required",
155        ));
156    }
157    let router = Arc::new(router);
158
159    let supervisor_node =
160        RunnableLambda::new(move |mut state: SupervisorState, ctx: ExecutionContext| {
161            let router = router.clone();
162            async move {
163                let decision = router.invoke(state.messages.clone(), &ctx).await?;
164                // Invariant #18 — handoff is auditable. Emit the
165                // (from, to) pair on every routed turn; `Finish` does
166                // not produce a handoff (the run terminates rather
167                // than transferring control to another named agent).
168                // `Agent` and `Handoff` share the same audit shape —
169                // the payload, when present, rides separately into
170                // the receiving agent's leading `system` message.
171                if let Some(name) = decision.agent_name()
172                    && let Some(handle) = ctx.audit_sink()
173                {
174                    handle
175                        .as_sink()
176                        .record_agent_handoff(state.last_speaker.as_deref(), name);
177                }
178                state.next_speaker = Some(decision);
179                Ok::<_, _>(state)
180            }
181        });
182
183    let mut graph = StateGraph::<SupervisorState>::new()
184        .add_node("supervisor", supervisor_node)
185        .set_entry_point("supervisor");
186
187    let finish_node =
188        RunnableLambda::new(|state: SupervisorState, _ctx| async move { Ok::<_, _>(state) });
189    graph = graph
190        .add_node("finish", finish_node)
191        .add_finish_point("finish");
192
193    // Routing keys: the typed `SupervisorDecision` is projected to a
194    // `String` only at the conditional-edge boundary the graph layer
195    // requires. Hallucination at the LLM router is foreclosed by the
196    // enum; a deterministic registry lookup at this boundary catches
197    // any mis-routing before the graph hands control to a non-node.
198    let known_names: std::collections::HashSet<String> =
199        agents.iter().map(|e| e.name.clone()).collect();
200    let mut conditional_mapping: Vec<(String, String)> =
201        vec![(FINISH_KEY.to_owned(), "finish".to_owned())];
202
203    for entry in agents {
204        let AgentEntry { name, agent } = entry;
205        let label = name.clone();
206        let node_name = name.clone();
207        let agent_node =
208            RunnableLambda::new(move |mut state: SupervisorState, ctx: ExecutionContext| {
209                let agent = agent.clone();
210                let label = label.clone();
211                async move {
212                    // Drain any handoff payload the supervisor staged
213                    // on this turn into a leading `system` message
214                    // so the receiving agent sees it before its own
215                    // turn. Agent / Finish carry no payload.
216                    if let Some(SupervisorDecision::Handoff { payload, .. }) =
217                        state.next_speaker.take()
218                    {
219                        let rendered = serde_json::to_string_pretty(&payload)
220                            .unwrap_or_else(|_| payload.to_string());
221                        state
222                            .messages
223                            .push(Message::system(format!("Handoff payload:\n{rendered}")));
224                    }
225                    let reply = agent.invoke(state.messages.clone(), &ctx).await?;
226                    state.messages.push(reply);
227                    state.last_speaker = Some(label.clone());
228                    state.next_speaker = None;
229                    Ok::<_, _>(state)
230                }
231            });
232        graph = graph
233            .add_node(node_name.clone(), agent_node)
234            .add_edge(node_name.clone(), "supervisor");
235        conditional_mapping.push((name.clone(), name));
236    }
237
238    graph = graph.add_conditional_edges(
239        "supervisor",
240        move |state: &SupervisorState| match state.next_speaker.as_ref().and_then(SupervisorDecision::agent_name) {
241            Some(name) if known_names.contains(name) => name.to_owned(),
242            // Either no decision yet (unreachable from the
243            // supervisor node, which always sets one) or an
244            // unknown-agent name (caller bug). Routing to FINISH
245            // surfaces graph completion rather than a dead-end
246            // deadlock; the unknown-name branch additionally trips
247            // a tracing event so the operator sees what happened.
248            Some(name) => {
249                tracing::warn!(
250                    target: "entelix_agents::supervisor",
251                    unknown_agent = %name,
252                    "supervisor router emitted decision routing to '{name}' but no AgentEntry by that name; finishing"
253                );
254                FINISH_KEY.to_owned()
255            }
256            None => FINISH_KEY.to_owned(),
257        },
258        conditional_mapping,
259    );
260
261    graph.compile()
262}
263
264/// Internal routing key the conditional-edge map uses for the
265/// `Finish` arm. Operator code never sees this — they speak in
266/// [`SupervisorDecision`].
267const FINISH_KEY: &str = "__finish__";
268
269/// Build a supervisor graph that picks among `agents` each turn.
270///
271/// `router` returns a [`SupervisorDecision`] given the conversation
272/// so far — either route to a named agent or finish the run. An
273/// empty `agents` list is rejected.
274pub fn create_supervisor_agent<R>(
275    router: R,
276    agents: Vec<AgentEntry>,
277) -> Result<Agent<SupervisorState>>
278where
279    R: Runnable<Vec<Message>, SupervisorDecision> + 'static,
280{
281    let compiled = build_supervisor_graph(router, agents)?;
282    Agent::<SupervisorState>::builder()
283        .with_name("supervisor")
284        .with_runnable(compiled)
285        .build()
286}
287
288/// Adapt a supervisor [`Agent<SupervisorState>`] into a
289/// `Runnable<Vec<Message>, Message>` so it can be embedded as one
290/// `AgentEntry` inside a parent supervisor — the nested-supervisor
291/// pattern.
292///
293/// The adapter feeds the parent's conversation into the nested
294/// supervisor's own state, runs it to completion, and returns the
295/// last message — typically the final assistant reply.
296pub fn team_from_supervisor(team: Agent<SupervisorState>) -> impl Runnable<Vec<Message>, Message> {
297    let team = Arc::new(team);
298    RunnableLambda::new(move |messages: Vec<Message>, ctx: ExecutionContext| {
299        let team = team.clone();
300        async move {
301            let state = SupervisorState {
302                messages,
303                last_speaker: None,
304                next_speaker: None,
305            };
306            let final_state = team.execute(state, &ctx).await?.into_state();
307            final_state.messages.last().cloned().ok_or_else(|| {
308                Error::invalid_request(
309                    "team_from_supervisor: team finished with empty conversation",
310                )
311            })
312        }
313    })
314}