1use 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#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum Refusal {
35 GroundStop,
37 NoSuchAgent {
39 agent: AgentName,
41 },
42 NoRoute {
47 from: AgentName,
49 to: AgentName,
51 },
52 NoSuchRunner {
54 agent: AgentName,
56 runner: String,
58 },
59 NoRunner {
61 agent: AgentName,
63 },
64 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#[derive(Debug, Clone)]
94pub struct Authorised<'a> {
95 pub agent: &'a Agent,
97 pub name: AgentName,
99 pub hops_remaining: u32,
104 pub runner: String,
106}
107
108pub 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 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#[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#[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 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 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 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 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 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 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 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 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 assert!(!declared_env(reader, defaults).contains(&"RELEASE_TOKEN".to_owned()));
563 }
564}