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}