Skip to main content

layover_tower/
dispatch.rs

1//! Deciding whether a flight may fly, and what starting it would mean.
2//!
3//! # Why this is separate from spawning
4//!
5//! Everything here is a refusal that should happen *before* a process exists. A flight down an
6//! edge the route map does not permit, a chain with no Hops left, an itinerary out of Fuel — each
7//! of those is cheaper to catch now than after a CLI has started and begun spending.
8//!
9//! Keeping the decision apart from the act also means it can be tested exhaustively without a
10//! process in sight, which matters because this is where the safety rails actually bite. A rail
11//! that is only exercised through a real spawn is a rail tested a handful of times.
12//!
13//! # The order the checks run in
14//!
15//! Ground Stop, then route, then rails. Not arbitrary:
16//!
17//! - **Ground Stop first**, because when everything is meant to have stopped, the reason a flight
18//!   was refused should be "everything is stopped" and not a detail about that particular flight.
19//! - **Route before rails**, because "that edge does not exist" is a fact about the factory's
20//!   definition and will be true on every attempt, whereas "no Fuel left" is a fact about this
21//!   chain right now. Reporting the permanent problem first saves somebody re-triggering work
22//!   that was never going to be permitted.
23
24use std::collections::BTreeMap;
25
26use layover_core::agent::{Agent, AgentName};
27use layover_core::config::Config;
28use layover_core::flight::Flight;
29use layover_core::graph::RouteGraph;
30use layover_core::itinerary::{Denial, Itinerary};
31
32/// Why a flight will not be flown.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum Refusal {
35    /// A Ground Stop is engaged, so nothing starts.
36    GroundStop,
37    /// The flight names an agent the factory does not declare.
38    NoSuchAgent {
39        /// The name that is not declared.
40        agent: AgentName,
41    },
42    /// The route map does not permit this edge.
43    ///
44    /// Not an error in the sending agent so much as a fact about the factory: the mesh is a
45    /// permission graph, and an edge that is not drawn is a message that may not be sent.
46    NoRoute {
47        /// Who tried to send.
48        from: AgentName,
49        /// Who they tried to reach.
50        to: AgentName,
51    },
52    /// The agent names a runner the factory does not declare.
53    NoSuchRunner {
54        /// The agent whose runner is missing.
55        agent: AgentName,
56        /// The runner that is not declared.
57        runner: String,
58    },
59    /// The agent declares no runner and there is no default.
60    NoRunner {
61        /// The agent with nothing to run it.
62        agent: AgentName,
63    },
64    /// A safety rail refused it.
65    Rail(Denial),
66}
67
68impl std::fmt::Display for Refusal {
69    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70        match self {
71            Self::GroundStop => f.write_str("a Ground Stop is engaged, so nothing new starts"),
72            Self::NoSuchAgent { agent } => write!(f, "no agent called `{agent}` is declared"),
73            Self::NoRoute { from, to } => write!(
74                f,
75                "the route map does not permit `{from}` to send to `{to}`"
76            ),
77            Self::NoSuchRunner { agent, runner } => write!(
78                f,
79                "agent `{agent}` names runner `{runner}`, which is not declared"
80            ),
81            Self::NoRunner { agent } => write!(
82                f,
83                "agent `{agent}` declares no runner and there is no `[defaults] runner`"
84            ),
85            Self::Rail(denial) => write!(f, "{denial}"),
86        }
87    }
88}
89
90impl std::error::Error for Refusal {}
91
92/// A flight that may fly, and what it costs the chain.
93#[derive(Debug, Clone)]
94pub struct Authorised<'a> {
95    /// The agent that will run.
96    pub agent: &'a Agent,
97    /// Its name.
98    pub name: AgentName,
99    /// Hops remaining for the run this starts.
100    ///
101    /// Already decremented: this is what the *next* flight from this agent will be authorised
102    /// against, and carrying it here is what stops an agent choosing its own depth budget.
103    pub hops_remaining: u32,
104    /// Which runner will invoke it.
105    pub runner: String,
106}
107
108/// Decides whether `flight` may be flown, and against which agent.
109///
110/// `sender` is `None` for a flight from a human — there is no upstream agent, so there is no edge
111/// to check. The rails still apply: a human trigger spends Hops and Fuel like anything else.
112///
113/// # Errors
114///
115/// Returns the first [`Refusal`] that applies, in the order documented on this module.
116pub fn authorise<'a>(
117    config: &'a Config,
118    graph: &RouteGraph,
119    itinerary: &Itinerary,
120    sender: Option<&AgentName>,
121    flight: &Flight,
122    ground_stop_engaged: bool,
123) -> Result<Authorised<'a>, Refusal> {
124    if ground_stop_engaged {
125        return Err(Refusal::GroundStop);
126    }
127
128    let name = flight.to.clone();
129    let agent = config
130        .agents
131        .get(&name)
132        .ok_or_else(|| Refusal::NoSuchAgent {
133            agent: name.clone(),
134        })?;
135
136    if let Some(from) = sender
137        && !graph.permits(from, &name)
138    {
139        return Err(Refusal::NoRoute {
140            from: from.clone(),
141            to: name.clone(),
142        });
143    }
144
145    // Hops are checked against what the *sender's* run had left, which the flight carries. An
146    // agent cannot award itself more depth than it was given, because the number never passes
147    // through the agent — the Tower reads it from the flight it minted.
148    let hops_remaining = itinerary
149        .authorize_send(flight.hops_remaining)
150        .map_err(Refusal::Rail)?;
151
152    let runner = agent
153        .runner
154        .clone()
155        .or_else(|| config.defaults.runner.clone())
156        .ok_or_else(|| Refusal::NoRunner {
157            agent: name.clone(),
158        })?;
159
160    if !config.runners.contains_key(&runner) {
161        return Err(Refusal::NoSuchRunner {
162            agent: name.clone(),
163            runner,
164        });
165    }
166
167    Ok(Authorised {
168        agent,
169        name,
170        hops_remaining,
171        runner,
172    })
173}
174
175/// Resolves the environment an agent needs: its own CLI's credentials and its MCP servers'.
176///
177/// Collected across every server the agent declares, because they are all started inside the one
178/// child process and share its environment, and combined with what the agent named for itself.
179/// `defaults` covers the credential every agent's CLI needs; it adds to the agent's own list
180/// rather than being overridden by it, because the two answer different questions — "what does
181/// this CLI need to start" and "what may this particular agent hold".
182#[must_use]
183pub fn declared_env(agent: &Agent, defaults: &[String]) -> Vec<String> {
184    let mut names: Vec<String> = agent
185        .mcp
186        .values()
187        .flat_map(|server| server.env_from.iter().cloned())
188        .chain(agent.env_from.iter().cloned())
189        .chain(defaults.iter().cloned())
190        .collect();
191
192    names.sort();
193    names.dedup();
194    names
195}
196
197/// Explicit values an agent's MCP servers set, as opposed to names they read from the environment.
198#[must_use]
199pub fn declared_values(agent: &Agent) -> BTreeMap<String, String> {
200    agent
201        .mcp
202        .values()
203        .flat_map(|server| server.env.iter().map(|(k, v)| (k.clone(), v.clone())))
204        .collect()
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210    use layover_core::flight::{ItineraryId, Origin};
211
212    const FACTORY: &str = r#"
213[layover]
214work_dir = "work"
215
216[defaults]
217runner = "claude"
218max_hops = 4
219fuel_usd = 5.0
220max_runs = 10
221
222[runners.claude]
223command = ["claude", "-p"]
224
225[agents.analyst]
226prompt = "analyse"
227entry = true
228
229[agents.developer]
230prompt = "develop"
231
232[agents.stranger]
233prompt = "lurk"
234
235[pipelines.build]
236entry = "analyst"
237
238[[routes]]
239from = "analyst"
240to = "developer"
241"#;
242
243    fn factory() -> (Config, RouteGraph) {
244        let config: Config = toml::from_str(FACTORY).expect("parses");
245        let graph = RouteGraph::from_config(&config);
246        (config, graph)
247    }
248
249    fn itinerary() -> Itinerary {
250        Itinerary::new(ItineraryId::generate(), 4, 5.0, 10)
251    }
252
253    fn flight_to(to: &str, hops: u32) -> Flight {
254        Flight::new(
255            ItineraryId::generate(),
256            Origin::Human,
257            AgentName::new(to),
258            "do the thing",
259            hops,
260        )
261    }
262
263    #[test]
264    fn a_human_trigger_needs_no_edge() {
265        // There is no upstream agent, so there is nothing to check an edge against. The rails
266        // still apply.
267        let (config, graph) = factory();
268        let authorised = authorise(
269            &config,
270            &graph,
271            &itinerary(),
272            None,
273            &flight_to("analyst", 4),
274            false,
275        )
276        .expect("a human may trigger an agent");
277
278        assert_eq!(authorised.name, AgentName::new("analyst"));
279        assert_eq!(authorised.runner, "claude");
280    }
281
282    #[test]
283    fn a_permitted_edge_is_flown() {
284        let (config, graph) = factory();
285        let sender = AgentName::new("analyst");
286
287        assert!(
288            authorise(
289                &config,
290                &graph,
291                &itinerary(),
292                Some(&sender),
293                &flight_to("developer", 4),
294                false
295            )
296            .is_ok()
297        );
298    }
299
300    #[test]
301    fn an_edge_the_map_does_not_draw_is_refused() {
302        // The mesh is a permission graph. An edge that is not drawn is a message that may not be
303        // sent, however reasonable it looks.
304        let (config, graph) = factory();
305        let sender = AgentName::new("developer");
306
307        let refusal = authorise(
308            &config,
309            &graph,
310            &itinerary(),
311            Some(&sender),
312            &flight_to("analyst", 4),
313            false,
314        )
315        .expect_err("developer may not send to analyst");
316
317        assert_eq!(
318            refusal,
319            Refusal::NoRoute {
320                from: AgentName::new("developer"),
321                to: AgentName::new("analyst"),
322            }
323        );
324    }
325
326    #[test]
327    fn a_ground_stop_refuses_before_anything_else_is_considered() {
328        // When everything is meant to have stopped, the reason should be that everything is
329        // stopped -- not a detail about this particular flight.
330        let (config, graph) = factory();
331        let sender = AgentName::new("developer");
332
333        let refusal = authorise(
334            &config,
335            &graph,
336            &itinerary(),
337            Some(&sender),
338            &flight_to("nonexistent", 0),
339            true,
340        )
341        .expect_err("a Ground Stop refuses everything");
342
343        assert_eq!(refusal, Refusal::GroundStop);
344    }
345
346    #[test]
347    fn a_chain_out_of_hops_is_cut() {
348        let (config, graph) = factory();
349
350        let refusal = authorise(
351            &config,
352            &graph,
353            &itinerary(),
354            None,
355            &flight_to("analyst", 0),
356            false,
357        )
358        .expect_err("zero hops ends the chain");
359
360        assert_eq!(refusal, Refusal::Rail(Denial::HopsExhausted));
361    }
362
363    #[test]
364    fn hops_come_back_decremented_so_an_agent_cannot_award_itself_more() {
365        // The count never passes through the agent: the Tower reads it from the flight it minted
366        // and hands the run what is left.
367        let (config, graph) = factory();
368        let authorised = authorise(
369            &config,
370            &graph,
371            &itinerary(),
372            None,
373            &flight_to("analyst", 3),
374            false,
375        )
376        .expect("authorised");
377
378        assert_eq!(authorised.hops_remaining, 2);
379    }
380
381    #[test]
382    fn an_exhausted_itinerary_refuses_on_the_rail_not_the_route() {
383        let (config, graph) = factory();
384        let mut spent = itinerary();
385        spent.debit_fuel(99.0);
386
387        let refusal = authorise(
388            &config,
389            &graph,
390            &spent,
391            None,
392            &flight_to("analyst", 4),
393            false,
394        )
395        .expect_err("no Fuel left");
396
397        assert_eq!(refusal, Refusal::Rail(Denial::FuelExhausted));
398    }
399
400    #[test]
401    fn an_undeclared_agent_is_refused_by_name() {
402        let (config, graph) = factory();
403
404        let refusal = authorise(
405            &config,
406            &graph,
407            &itinerary(),
408            None,
409            &flight_to("ghost", 4),
410            false,
411        )
412        .expect_err("no such agent");
413
414        assert!(matches!(refusal, Refusal::NoSuchAgent { .. }));
415        assert!(refusal.to_string().contains("ghost"), "{refusal}");
416    }
417
418    #[test]
419    fn a_route_problem_is_reported_before_a_rail_problem() {
420        // "That edge does not exist" is true on every attempt; "no Fuel left" is true of this
421        // chain right now. Reporting the permanent one first saves re-triggering work that was
422        // never going to be permitted.
423        let (config, graph) = factory();
424        let mut spent = itinerary();
425        spent.debit_fuel(99.0);
426        let sender = AgentName::new("developer");
427
428        let refusal = authorise(
429            &config,
430            &graph,
431            &spent,
432            Some(&sender),
433            &flight_to("analyst", 4),
434            false,
435        )
436        .expect_err("refused");
437
438        assert!(
439            matches!(refusal, Refusal::NoRoute { .. }),
440            "got {refusal:?}, expected the route problem to win"
441        );
442    }
443
444    #[test]
445    fn an_agent_with_no_runner_anywhere_is_refused() {
446        let text = FACTORY.replace("runner = \"claude\"\n", "");
447        let config: Config = toml::from_str(&text).expect("parses");
448        let graph = RouteGraph::from_config(&config);
449
450        let refusal = authorise(
451            &config,
452            &graph,
453            &itinerary(),
454            None,
455            &flight_to("analyst", 4),
456            false,
457        )
458        .expect_err("nothing to run it");
459
460        assert!(matches!(refusal, Refusal::NoRunner { .. }), "{refusal:?}");
461    }
462
463    #[test]
464    fn credentials_are_gathered_from_every_declared_server_without_duplicates() {
465        let text = r#"
466[layover]
467work_dir = "work"
468
469[defaults]
470runner = "claude"
471
472[runners.claude]
473command = ["claude", "-p"]
474
475[agents.analyst]
476prompt = "analyse"
477entry = true
478
479[agents.analyst.mcp.one]
480url = "https://example.invalid/"
481env_from = ["SHARED_TOKEN", "ONE_TOKEN"]
482
483[agents.analyst.mcp.two]
484url = "https://example.invalid/"
485env_from = ["SHARED_TOKEN", "TWO_TOKEN"]
486"#;
487        let config: Config = toml::from_str(text).expect("parses");
488        let agent = config
489            .agents
490            .get(&AgentName::new("analyst"))
491            .expect("declared");
492
493        assert_eq!(
494            declared_env(agent, &[]),
495            vec!["ONE_TOKEN", "SHARED_TOKEN", "TWO_TOKEN"],
496            "a name declared twice is still one variable"
497        );
498    }
499
500    #[test]
501    fn an_agent_can_name_its_own_cli_credentials_alongside_its_servers() {
502        // The agent CLI needs a credential before it can do anything at all, and it is not the
503        // same credential its MCP servers need. Without this the child authenticates as nobody
504        // and the run dies before it reads its instructions.
505        let text = r#"
506[agents.analyst]
507prompt = "go"
508env_from = ["GITHUB_TOKEN"]
509
510[agents.analyst.mcp.kusto]
511url = "https://example.invalid/"
512env_from = ["KUSTO_TOKEN"]
513"#;
514        let config: Config = toml::from_str(text).expect("parses");
515        let agent = config
516            .agents
517            .get(&AgentName::new("analyst"))
518            .expect("declared");
519
520        assert_eq!(
521            declared_env(agent, &[]),
522            vec!["GITHUB_TOKEN", "KUSTO_TOKEN"]
523        );
524    }
525
526    #[test]
527    fn defaults_add_to_an_agents_own_names_rather_than_replacing_them() {
528        // One CLI credential shared by every agent, plus whatever this one alone may hold. If
529        // defaults were overridden, naming a private token would silently drop the shared one and
530        // the agent would fail to authenticate.
531        let text = r#"
532[defaults]
533env_from = ["GITHUB_TOKEN"]
534
535[agents.publisher]
536prompt = "go"
537env_from = ["RELEASE_TOKEN"]
538
539[agents.reader]
540prompt = "go"
541"#;
542        let config: Config = toml::from_str(text).expect("parses");
543        let defaults = &config.defaults.env_from;
544
545        let publisher = config
546            .agents
547            .get(&AgentName::new("publisher"))
548            .expect("declared");
549        assert_eq!(
550            declared_env(publisher, defaults),
551            vec!["GITHUB_TOKEN", "RELEASE_TOKEN"]
552        );
553
554        // And the agent that named nothing still gets the shared one.
555        let reader = config
556            .agents
557            .get(&AgentName::new("reader"))
558            .expect("declared");
559        assert_eq!(declared_env(reader, defaults), vec!["GITHUB_TOKEN"]);
560
561        // The publishing token stays with the publisher.
562        assert!(!declared_env(reader, defaults).contains(&"RELEASE_TOKEN".to_owned()));
563    }
564}