Skip to main content

layover_tower/
runtime.rs

1//! What a tool call actually does, once it reaches the factory.
2//!
3//! # Why a chain shares one itinerary
4//!
5//! The rails are per-chain, not per-run: Hops bound how far a *causal chain* travels, Fuel bounds
6//! what that chain may spend in total, the run cap bounds how many runs it may start. A flight
7//! sent by an agent continues the chain that woke it, so it must be accounted against the same
8//! itinerary — mint a fresh one and every rail resets, and a loop between two agents runs forever
9//! on a budget that is renewed each time round.
10//!
11//! That is why itineraries are held here and looked up by identifier rather than constructed per
12//! flight.
13
14use std::collections::HashMap;
15use std::path::PathBuf;
16use std::sync::{Arc, Mutex};
17
18use layover_core::agent::AgentName;
19use layover_core::config::Config;
20use layover_core::flight::{Flight, ItineraryId, Origin};
21use layover_core::graph::RouteGraph;
22use layover_core::handover::Handover;
23use layover_core::itinerary::Itinerary;
24use layover_core::layover::Layover;
25use layover_core::learning::{Impact, Learnings, Proposal, Uptake};
26use layover_core::pipeline::PipelineName;
27use layover_core::queue::Queued;
28use layover_mcp::{Peer, Runtime, Session, ToolError};
29
30/// The itineraries a running factory is accounting against.
31///
32/// One per causal chain, created when the chain begins and reused by every flight within it.
33#[derive(Debug, Default)]
34pub struct Chains {
35    live: Mutex<HashMap<String, Itinerary>>,
36    /// Which pipeline each chain was triggered through.
37    ///
38    /// Held here rather than carried on every flight because only the *first* flight of a chain
39    /// knows: a flight an agent sends has no pipeline of its own, and labelling only the first hop
40    /// would leave the rest of a chain looking like it belonged to nothing.
41    pipelines: Mutex<HashMap<String, PipelineName>>,
42}
43
44impl Chains {
45    /// An empty set of chains.
46    #[must_use]
47    pub fn new() -> Self {
48        Self::default()
49    }
50
51    /// Runs `act` against the itinerary for `id`, creating it on first sight.
52    ///
53    /// Creating on first sight rather than requiring registration means a queued flight from a
54    /// previous process still lands in a chain with the configured rails, instead of being
55    /// refused for belonging to an itinerary this process has never heard of.
56    pub fn with<T>(
57        &self,
58        id: &ItineraryId,
59        defaults: &layover_core::config::Defaults,
60        act: impl FnOnce(&mut Itinerary) -> T,
61    ) -> Option<T> {
62        let mut live = self.live.lock().ok()?;
63        let chain = live.entry(id.as_str().to_owned()).or_insert_with(|| {
64            Itinerary::new(
65                id.clone(),
66                defaults.max_hops,
67                defaults.fuel_usd,
68                defaults.max_runs,
69            )
70        });
71
72        Some(act(chain))
73    }
74
75    /// How many chains are being accounted, for reporting.
76    #[must_use]
77    pub fn count(&self) -> usize {
78        self.live.lock().map_or(0, |live| live.len())
79    }
80
81    /// Remembers which pipeline opened a chain.
82    ///
83    /// Only the first flight of a chain carries one, so this is recorded once and read by every
84    /// run after it.
85    pub fn opened_by(&self, id: &ItineraryId, pipeline: Option<&PipelineName>) {
86        let Some(pipeline) = pipeline else {
87            return;
88        };
89
90        if let Ok(mut known) = self.pipelines.lock() {
91            known
92                .entry(id.as_str().to_owned())
93                .or_insert_with(|| pipeline.clone());
94        }
95    }
96
97    /// Which pipeline a chain was triggered through, if it is known.
98    #[must_use]
99    pub fn pipeline_of(&self, id: &ItineraryId) -> Option<PipelineName> {
100        self.pipelines.lock().ok()?.get(id.as_str()).cloned()
101    }
102}
103
104/// Everything a tool call needs, wired to a real factory.
105///
106/// Owns rather than borrows, because this has to live in an HTTP handler that outlives any
107/// particular call and is shared across threads.
108/// How a runtime reads the factory's accumulated learnings.
109pub type ReadLearnings = Arc<dyn Fn() -> Result<Learnings, String> + Send + Sync>;
110
111/// How it writes them back.
112pub type WriteLearnings = Arc<dyn Fn(&Learnings) -> Result<(), String> + Send + Sync>;
113
114/// Where a sent flight goes.
115pub type QueueFlight = Arc<dyn Fn(Queued) -> Result<(), String> + Send + Sync>;
116
117/// Where work set down goes.
118pub type BookLayover = Arc<dyn Fn(Layover) -> Result<(), String> + Send + Sync>;
119
120/// Everything a tool call needs, wired to a real factory.
121///
122/// Owns rather than borrows, because this has to live in an HTTP handler that outlives any
123/// particular call and is shared across threads.
124pub struct FactoryRuntime {
125    config: Arc<Config>,
126    graph: Arc<RouteGraph>,
127    queue: QueueFlight,
128    book: BookLayover,
129    read_learnings: ReadLearnings,
130    write_learnings: WriteLearnings,
131    hangars: PathBuf,
132    logbook: PathBuf,
133}
134
135/// Where a runtime reads and writes everything outside itself.
136///
137/// A struct rather than six positional arguments: they are all closures or paths, so the compiler
138/// would not catch two of them being swapped, and swapping the queue for the layover shelf is the
139/// kind of mistake that only shows up in production.
140pub struct Wiring {
141    /// The factory definition.
142    pub config: Arc<Config>,
143    /// Its route map.
144    pub graph: Arc<RouteGraph>,
145    /// Where agents' own notes live.
146    pub hangars: PathBuf,
147    /// The factory's shared memory.
148    pub logbook: PathBuf,
149    /// Where a sent flight goes.
150    pub queue: QueueFlight,
151    /// Where work set down goes.
152    pub book: BookLayover,
153    /// How to read what the factory has learned.
154    pub read_learnings: ReadLearnings,
155    /// How to write it back.
156    pub write_learnings: WriteLearnings,
157}
158
159impl FactoryRuntime {
160    /// Wires a runtime to a factory definition and the places it keeps things.
161    #[must_use]
162    pub fn new(wiring: Wiring) -> Self {
163        Self {
164            config: wiring.config,
165            graph: wiring.graph,
166            queue: wiring.queue,
167            book: wiring.book,
168            read_learnings: wiring.read_learnings,
169            write_learnings: wiring.write_learnings,
170            hangars: wiring.hangars,
171            logbook: wiring.logbook,
172        }
173    }
174}
175
176impl Runtime for FactoryRuntime {
177    fn peers(&self, session: &Session) -> Vec<Peer> {
178        self.graph
179            .successors(&session.agent)
180            .map(|name| Peer {
181                name: name.clone(),
182                description: self
183                    .config
184                    .agents
185                    .get(name)
186                    .and_then(|agent| agent.description.clone()),
187                spawns: self.graph.is_spawn(&session.agent, name),
188            })
189            .collect()
190    }
191
192    fn send(&self, session: &Session, to: &AgentName, body: &str) -> Result<String, ToolError> {
193        if !self.config.agents.contains_key(to) {
194            return Err(ToolError::NoSuchAgent { agent: to.clone() });
195        }
196
197        if !self.graph.permits(&session.agent, to) {
198            return Err(ToolError::NotPermitted {
199                from: session.agent.clone(),
200                to: to.clone(),
201            });
202        }
203
204        // A spawn edge is the one case where Hops do not apply: it is not continuing this chain,
205        // it is starting another. Checking the caller's remaining Hops would refuse a fan-out for
206        // a budget the new chain does not draw on.
207        let spawns = self.graph.is_spawn(&session.agent, to);
208
209        // Refused here as well as at dispatch, because being told now is worth more than being
210        // told later: the agent can report what it could not pass on, rather than finishing
211        // believing it handed the work over.
212        if !spawns && session.hops_remaining == 0 {
213            return Err(ToolError::Refused {
214                because: "this chain has no messages left; finish and report instead of sending"
215                    .to_owned(),
216            });
217        }
218
219        // A spawn edge opens a fresh itinerary, with its own Hops, Fuel and run cap; every other
220        // edge continues the caller's. Minting a fresh itinerary for an ordinary edge would reset
221        // every rail, and a loop between two agents would run forever on a renewed budget.
222        //
223        // The reverse mistake is subtler and is why `mode` is declared rather than inferred: a
224        // fan-out of twenty pull-request reviews sharing one chain would have the twenty-first
225        // review refused for a budget the first twenty spent.
226        let (itinerary, hops) = if spawns {
227            (ItineraryId::generate(), self.config.defaults.max_hops)
228        } else {
229            (session.itinerary.clone(), session.hops_remaining)
230        };
231
232        let flight = Flight::new(
233            itinerary,
234            Origin::Agent(session.agent.clone()),
235            to.clone(),
236            body,
237            hops,
238        );
239        let id = flight.id.as_str().to_owned();
240
241        (self.queue)(Queued::new(flight, None, std::collections::BTreeMap::new()))
242            .map_err(|detail| ToolError::Unavailable { detail })?;
243
244        Ok(id)
245    }
246
247    fn report(&self, session: &Session, headline: &str, body: &str) -> Result<(), ToolError> {
248        let report = layover_core::report::Report::new(
249            session.run.clone(),
250            session.agent.clone(),
251            session.itinerary.clone(),
252            headline,
253            body,
254            jiff::Timestamp::now(),
255        );
256
257        let path = self.agent_dir(&session.agent).join("reports.jsonl");
258        append_json(&path, &report).map_err(|detail| ToolError::Unavailable { detail })
259    }
260
261    fn help(
262        &self,
263        session: &Session,
264        summary: &str,
265        detail: &str,
266        fatal: bool,
267    ) -> Result<(), ToolError> {
268        let mut request = layover_core::help::HelpRequest::new(
269            session.agent.clone(),
270            session.run.clone(),
271            session.itinerary.clone(),
272            layover_core::help::Blocker::Other,
273            summary,
274            detail,
275            jiff::Timestamp::now(),
276        );
277        request.fatal = fatal;
278
279        let path = self.agent_dir(&session.agent).join("help.jsonl");
280        append_json(&path, &request).map_err(|detail| ToolError::Unavailable { detail })
281    }
282
283    fn memory_read(&self, session: &Session) -> Result<String, ToolError> {
284        let path = self.agent_dir(&session.agent).join("memory.md");
285
286        match std::fs::read_to_string(&path) {
287            Ok(text) => Ok(text),
288            // Nothing written yet is not a failure; it is the first run of this agent.
289            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
290                Ok("You have written nothing down yet.".to_owned())
291            }
292            Err(error) => Err(ToolError::Unavailable {
293                detail: error.to_string(),
294            }),
295        }
296    }
297
298    fn memory_write(&self, session: &Session, text: &str) -> Result<(), ToolError> {
299        let dir = self.agent_dir(&session.agent);
300        std::fs::create_dir_all(&dir).map_err(|error| ToolError::Unavailable {
301            detail: error.to_string(),
302        })?;
303
304        let path = dir.join("memory.md");
305        let mut existing = std::fs::read_to_string(&path).unwrap_or_default();
306        if !existing.is_empty() && !existing.ends_with('\n') {
307            existing.push('\n');
308        }
309        existing.push_str(text.trim());
310        existing.push('\n');
311
312        std::fs::write(&path, existing).map_err(|error| ToolError::Unavailable {
313            detail: error.to_string(),
314        })
315    }
316
317    fn wait(&self, session: &Session, until: &str, because: &str) -> Result<String, ToolError> {
318        let wait = parse_wait(until).ok_or_else(|| ToolError::BadArguments {
319            detail: format!(
320                "`{until}` is not a length of time. Use a number and a unit — `30m`, `2h`, `3d` — \
321                 which is how long to wait before this is looked at again."
322            ),
323        })?;
324
325        let now = jiff::Timestamp::now();
326        let due_at = now
327            .checked_add(jiff::SignedDuration::from_secs(wait))
328            .map_err(|_| ToolError::BadArguments {
329                detail: format!("`{until}` is further away than this factory can plan for"),
330            })?;
331
332        // The handover carries no flights and no progress. A layover is not a recovery: the run
333        // that booked it finished, so there is nothing half-done to hand over — what the later run
334        // needs is why it is here, which `waiting_for` carries.
335        let handover = Handover::dispatch(Vec::new());
336
337        let layover = Layover::book(
338            session.agent.clone(),
339            session.itinerary.clone(),
340            because,
341            handover,
342            now,
343            due_at,
344            DEFAULT_MAX_CHECKS,
345        );
346
347        let when = layover.due_at.to_string();
348
349        (self.book)(layover).map_err(|detail| ToolError::Unavailable { detail })?;
350
351        Ok(format!(
352            "Set down. This will be picked up no sooner than {when}, by a pipeline that resumes \
353             layovers. Finish and report now — nothing is kept running in the meantime."
354        ))
355    }
356
357    fn learn(&self, session: &Session, text: &str) -> Result<String, ToolError> {
358        let proposal = Proposal::new(
359            session.agent.clone(),
360            text,
361            // The agent's own rating of its own work, and not load-bearing: a learning becomes
362            // permanent through independent rediscovery, which is evidence, rather than through
363            // how important its author said it was. Medium because there is nothing to read it
364            // from and inventing a scale for the agent to game would be worse.
365            Impact::Medium,
366            jiff::Timestamp::now(),
367        );
368
369        let mut learnings =
370            (self.read_learnings)().map_err(|detail| ToolError::Unavailable { detail })?;
371
372        let uptake = learnings.propose(&proposal);
373
374        // Malformed and Refused change nothing, so writing would be a needless rewrite of the
375        // whole file — and `Refused` writing anything at all would let repetition look like it
376        // had an effect.
377        if !matches!(
378            uptake,
379            Uptake::Malformed | Uptake::Refused | Uptake::Echo | Uptake::Unacceptable(_)
380        ) {
381            (self.write_learnings)(&learnings)
382                .map_err(|detail| ToolError::Unavailable { detail })?;
383        }
384
385        // Said differently for each outcome, because they are not interchangeable and an agent
386        // that hears "noted" every time learns nothing about what its proposals are worth.
387        Ok(match uptake {
388            Uptake::Taken => "Noted. Future runs of you will be given this until it lapses, and \
389                              it becomes permanent if later runs arrive at it independently."
390                .to_owned(),
391            Uptake::Echo => "You were already told this, so repeating it is not evidence of \
392                             anything. It stands as it was."
393                .to_owned(),
394            Uptake::Rediscovered { proposals } => format!(
395                "Rediscovered — proposed independently {proposals} time(s) now, so it applies \
396                 again and is closer to becoming permanent."
397            ),
398            Uptake::Confirmed => "Rediscovered often enough to be treated as real. It will be \
399                                  given to future runs indefinitely."
400                .to_owned(),
401            Uptake::Refused => {
402                return Err(ToolError::Refused {
403                    because: "a human rejected this, and proposing it again does not reopen it. \
404                              If it is genuinely true now, say so in a report."
405                        .to_owned(),
406                });
407            }
408            Uptake::Malformed => {
409                return Err(ToolError::BadArguments {
410                    detail: "a learning is one or two sentences. Empty text, or more than will \
411                             fit in a prompt alongside everything else, is not one."
412                        .to_owned(),
413                });
414            }
415            Uptake::Unacceptable(reason) => {
416                return Err(ToolError::Refused {
417                    because: reason.to_string(),
418                });
419            }
420        })
421    }
422
423    fn logbook_append(&self, session: &Session, text: &str) -> Result<(), ToolError> {
424        use std::io::Write as _;
425
426        let line = text.trim();
427        if line.is_empty() {
428            return Err(ToolError::BadArguments {
429                detail: "the logbook is read by every agent; an empty entry is noise".to_owned(),
430            });
431        }
432
433        if let Some(parent) = self.logbook.parent() {
434            std::fs::create_dir_all(parent).map_err(|error| ToolError::Unavailable {
435                detail: error.to_string(),
436            })?;
437        }
438
439        // Stamped with who wrote it and when. The logbook is shared, so an entry nobody can
440        // attribute is one nobody can follow up or correct.
441        let entry = format!(
442            "\n## {} — `{}`\n\n{line}\n",
443            jiff::Timestamp::now(),
444            session.agent
445        );
446
447        std::fs::OpenOptions::new()
448            .create(true)
449            .append(true)
450            .open(&self.logbook)
451            .and_then(|mut file| file.write_all(entry.as_bytes()))
452            .map_err(|error| ToolError::Unavailable {
453                detail: error.to_string(),
454            })
455    }
456}
457
458/// How many fruitless checks a layover gets before it is given up on.
459///
460/// Twelve, against the backoff in `layover_core::layover`, is a little over two days of looking.
461/// Long enough for a review to come back over a weekend; short enough that something nobody ever
462/// answers stops costing money.
463const DEFAULT_MAX_CHECKS: u32 = 12;
464
465/// Reads a wait as a number of seconds.
466///
467/// Same vocabulary as a pipeline's `every`, deliberately: an operator who has written `every =
468/// "2h"` should not have to learn a second way to say two hours in order to read a prompt.
469fn parse_wait(text: &str) -> Option<i64> {
470    let trimmed = text.trim();
471    let (digits, unit) = match trimmed.char_indices().next_back() {
472        Some((index, unit)) => (&trimmed[..index], unit),
473        None => return None,
474    };
475
476    let multiplier = match unit {
477        's' => 1_i64,
478        'm' => 60,
479        'h' => 60 * 60,
480        'd' => 24 * 60 * 60,
481        _ => return None,
482    };
483
484    digits.trim().parse::<i64>().ok()?.checked_mul(multiplier)
485}
486
487impl FactoryRuntime {
488    /// Where one agent's own files live.
489    fn agent_dir(&self, agent: &AgentName) -> PathBuf {
490        self.hangars.join(agent.to_string())
491    }
492}
493
494/// Appends one JSON record to a file, creating it if needed.
495fn append_json<T: serde::Serialize>(path: &std::path::Path, value: &T) -> Result<(), String> {
496    use std::io::Write as _;
497
498    if let Some(parent) = path.parent() {
499        std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
500    }
501
502    let mut line = serde_json::to_string(value).map_err(|error| error.to_string())?;
503    line.push('\n');
504
505    std::fs::OpenOptions::new()
506        .create(true)
507        .append(true)
508        .open(path)
509        .and_then(|mut file| file.write_all(line.as_bytes()))
510        .map_err(|error| error.to_string())
511}
512
513#[cfg(test)]
514mod tests {
515    use super::*;
516    use layover_core::flight::RunId;
517
518    const FACTORY: &str = r#"
519[layover]
520work_dir = "work"
521
522[defaults]
523runner = "shell"
524max_hops = 4
525fuel_usd = 5.0
526max_runs = 10
527
528[runners.shell]
529command = ["echo"]
530
531[agents.analyst]
532description = "Works out what a request means"
533prompt = "analyse"
534entry = true
535
536[agents.developer]
537description = "Writes the code"
538prompt = "develop"
539
540[agents.stranger]
541prompt = "lurk"
542
543[agents.reviewer]
544prompt = "review one pull request"
545
546[pipelines.build]
547entry = "analyst"
548
549[[routes]]
550from = "analyst"
551to = "developer"
552
553[[routes]]
554from = "analyst"
555to = "reviewer"
556mode = "spawn"
557"#;
558
559    /// A runtime over a temporary directory, with everything it queued or booked kept for
560    /// inspection.
561    struct Fixture {
562        runtime: FactoryRuntime,
563        sent: Arc<Mutex<Vec<Queued>>>,
564        booked: Arc<Mutex<Vec<Layover>>>,
565        learnings: Arc<Mutex<Learnings>>,
566        defaults: layover_core::config::Defaults,
567        dir: PathBuf,
568    }
569
570    impl Fixture {
571        fn new(name: &str) -> Self {
572            let config: Config = toml::from_str(FACTORY).expect("the fixture factory parses");
573            let graph = RouteGraph::from_config(&config);
574            let defaults = config.defaults.clone();
575            let dir =
576                std::env::temp_dir().join(format!("layover-rt-{name}-{}", std::process::id()));
577            let _ = std::fs::remove_dir_all(&dir);
578            std::fs::create_dir_all(&dir).expect("a temporary directory");
579
580            let sent: Arc<Mutex<Vec<Queued>>> = Arc::new(Mutex::new(Vec::new()));
581            let sink = Arc::clone(&sent);
582            let booked: Arc<Mutex<Vec<Layover>>> = Arc::new(Mutex::new(Vec::new()));
583            let shelf = Arc::clone(&booked);
584            let learnings: Arc<Mutex<Learnings>> = Arc::new(Mutex::new(Learnings::new()));
585            let reading = Arc::clone(&learnings);
586            let writing = Arc::clone(&learnings);
587
588            Self {
589                runtime: FactoryRuntime::new(Wiring {
590                    config: Arc::new(config),
591                    graph: Arc::new(graph),
592                    hangars: dir.clone(),
593                    logbook: dir.join("logbook.md"),
594                    queue: Arc::new(move |queued| {
595                        sink.lock().map_err(|_| "poisoned".to_owned())?.push(queued);
596                        Ok(())
597                    }),
598                    book: Arc::new(move |layover| {
599                        shelf
600                            .lock()
601                            .map_err(|_| "poisoned".to_owned())?
602                            .push(layover);
603                        Ok(())
604                    }),
605                    read_learnings: Arc::new(move || {
606                        Ok(reading.lock().map_err(|_| "poisoned".to_owned())?.clone())
607                    }),
608                    write_learnings: Arc::new(move |updated| {
609                        *writing.lock().map_err(|_| "poisoned".to_owned())? = updated.clone();
610                        Ok(())
611                    }),
612                }),
613                sent,
614                booked,
615                learnings,
616                defaults,
617                dir,
618            }
619        }
620
621        fn sent(&self) -> Vec<Queued> {
622            self.sent.lock().expect("not poisoned").clone()
623        }
624
625        fn booked(&self) -> Vec<Layover> {
626            self.booked.lock().expect("not poisoned").clone()
627        }
628
629        fn learnings(&self) -> Learnings {
630            self.learnings.lock().expect("not poisoned").clone()
631        }
632    }
633
634    impl Drop for Fixture {
635        fn drop(&mut self) {
636            let _ = std::fs::remove_dir_all(&self.dir);
637        }
638    }
639
640    fn session(agent: &str, hops: u32) -> Session {
641        Session {
642            run: RunId::generate(),
643            agent: AgentName::new(agent),
644            itinerary: ItineraryId::generate(),
645            hops_remaining: hops,
646        }
647    }
648
649    #[test]
650    fn peers_are_what_the_route_map_permits_and_nothing_else() {
651        let fixture = Fixture::new("peers");
652
653        let peers = fixture.runtime.peers(&session("analyst", 3));
654        let names: Vec<String> = peers.iter().map(|peer| peer.name.to_string()).collect();
655
656        assert_eq!(names, ["developer", "reviewer"], "the two drawn edges");
657        assert!(
658            !names.contains(&"stranger".to_owned()),
659            "an agent with no edge from `analyst` is not a peer"
660        );
661        assert_eq!(peers[0].description.as_deref(), Some("Writes the code"));
662    }
663
664    #[test]
665    fn a_sent_flight_continues_the_chain_rather_than_starting_one() {
666        // The rails are per-chain. Minting a fresh itinerary here would reset Hops, Fuel and the
667        // run cap, so a loop between two agents would run forever on a renewed budget.
668        let fixture = Fixture::new("continues");
669        let caller = session("analyst", 3);
670
671        fixture
672            .runtime
673            .send(&caller, &AgentName::new("developer"), "fix it")
674            .expect("the route is drawn");
675
676        let sent = fixture.sent();
677        assert_eq!(sent.len(), 1);
678        assert_eq!(
679            sent[0].flight.itinerary, caller.itinerary,
680            "the flight must belong to the chain that sent it"
681        );
682        assert_eq!(sent[0].flight.hops_remaining, 3);
683    }
684
685    #[test]
686    fn a_sent_flight_records_which_agent_sent_it() {
687        // A joined agent receives several flights at once and has to tell them apart.
688        let fixture = Fixture::new("origin");
689
690        fixture
691            .runtime
692            .send(&session("analyst", 2), &AgentName::new("developer"), "go")
693            .expect("the route is drawn");
694
695        assert_eq!(
696            fixture.sent()[0].flight.from,
697            Origin::Agent(AgentName::new("analyst"))
698        );
699    }
700
701    #[test]
702    fn an_edge_the_map_does_not_draw_is_refused_with_advice() {
703        let fixture = Fixture::new("refused");
704
705        let error = fixture
706            .runtime
707            .send(&session("analyst", 3), &AgentName::new("stranger"), "go")
708            .expect_err("no such edge");
709
710        assert!(matches!(error, ToolError::NotPermitted { .. }));
711        assert!(
712            error.to_string().contains("layover_peers"),
713            "a refusal should say how to find out what is permitted: {error}"
714        );
715        assert!(fixture.sent().is_empty(), "nothing may be queued");
716    }
717
718    #[test]
719    fn sending_to_an_agent_that_does_not_exist_says_so() {
720        let fixture = Fixture::new("ghost");
721
722        let error = fixture
723            .runtime
724            .send(&session("analyst", 3), &AgentName::new("ghost"), "go")
725            .expect_err("no such agent");
726
727        assert!(matches!(error, ToolError::NoSuchAgent { .. }), "{error}");
728    }
729
730    #[test]
731    fn a_chain_with_no_hops_left_is_told_to_finish_rather_than_send() {
732        // Being told now is worth more than being told at dispatch: the agent can report what it
733        // could not pass on, instead of finishing in the belief that it handed the work over.
734        let fixture = Fixture::new("nohops");
735
736        let error = fixture
737            .runtime
738            .send(&session("analyst", 0), &AgentName::new("developer"), "go")
739            .expect_err("out of hops");
740
741        assert!(error.to_string().contains("report"), "{error}");
742        assert!(fixture.sent().is_empty(), "nothing may be queued");
743    }
744
745    #[test]
746    fn a_spawn_edge_opens_a_new_chain_with_its_own_budget() {
747        // A fan-out of twenty pull-request reviews sharing one chain would have the twenty-first
748        // refused for a budget the first twenty spent. That is what `mode = "spawn"` exists for.
749        let fixture = Fixture::new("spawn");
750        let caller = session("analyst", 2);
751
752        fixture
753            .runtime
754            .send(&caller, &AgentName::new("reviewer"), "review #41")
755            .expect("the spawn edge is drawn");
756
757        let sent = fixture.sent();
758        assert_ne!(
759            sent[0].flight.itinerary, caller.itinerary,
760            "a spawn edge starts a chain rather than continuing one"
761        );
762        assert_eq!(
763            sent[0].flight.hops_remaining, fixture.defaults.max_hops,
764            "the new chain gets the configured budget, not the caller's remainder"
765        );
766    }
767
768    #[test]
769    fn a_spawn_may_be_sent_even_when_the_caller_has_no_hops_left() {
770        // Hops bound one causal chain. A spawn is not continuing this one, so refusing it would
771        // charge the new chain for a budget it does not draw on.
772        let fixture = Fixture::new("spawn-nohops");
773
774        let id = fixture
775            .runtime
776            .send(&session("analyst", 0), &AgentName::new("reviewer"), "go")
777            .expect("a spawn does not spend the caller's hops");
778
779        assert!(!id.is_empty());
780        assert_eq!(fixture.sent().len(), 1);
781    }
782
783    #[test]
784    fn a_spawn_edge_is_still_an_edge_the_route_map_has_to_draw() {
785        let fixture = Fixture::new("spawn-refused");
786
787        let error = fixture
788            .runtime
789            .send(&session("developer", 3), &AgentName::new("reviewer"), "go")
790            .expect_err("no edge from developer to reviewer");
791
792        assert!(matches!(error, ToolError::NotPermitted { .. }), "{error}");
793    }
794
795    #[test]
796    fn peers_say_which_of_them_open_a_new_chain() {
797        // An agent deciding where work goes should be able to tell a hand-off from a fan-out.
798        let fixture = Fixture::new("spawn-peers");
799
800        let peers = fixture.runtime.peers(&session("analyst", 3));
801        let reviewer = peers
802            .iter()
803            .find(|peer| peer.name == AgentName::new("reviewer"))
804            .expect("reviewer is reachable");
805        let developer = peers
806            .iter()
807            .find(|peer| peer.name == AgentName::new("developer"))
808            .expect("developer is reachable");
809
810        assert!(reviewer.spawns, "the spawn edge is marked");
811        assert!(!developer.spawns, "an ordinary edge is not");
812    }
813
814    #[test]
815    fn booking_a_layover_sets_the_work_down_and_says_when_it_returns() {
816        let fixture = Fixture::new("wait");
817
818        let answer = fixture
819            .runtime
820            .wait(&session("analyst", 3), "2h", "the review to land")
821            .expect("2h is a length of time");
822
823        assert!(answer.contains("Set down"), "{answer}");
824        assert!(
825            answer.contains("Finish and report"),
826            "an agent must be told not to wait: {answer}"
827        );
828
829        let booked = fixture.booked();
830        assert_eq!(booked.len(), 1);
831        assert_eq!(booked[0].agent, AgentName::new("analyst"));
832        assert_eq!(booked[0].waiting_for, "the review to land");
833    }
834
835    #[test]
836    fn a_layover_comes_back_to_the_chain_that_booked_it() {
837        // The resumed run is told which chain set this down, which is the only thread back to
838        // what it was about.
839        let fixture = Fixture::new("wait-chain");
840        let caller = session("analyst", 3);
841
842        fixture
843            .runtime
844            .wait(&caller, "1d", "the build to go green")
845            .expect("books");
846
847        assert_eq!(fixture.booked()[0].booked_by, caller.itinerary);
848    }
849
850    #[test]
851    fn a_layover_is_not_due_before_its_time() {
852        let fixture = Fixture::new("wait-due");
853
854        fixture
855            .runtime
856            .wait(&session("analyst", 3), "2h", "something")
857            .expect("books");
858
859        let booked = &fixture.booked()[0];
860        assert!(!booked.is_due(jiff::Timestamp::now()));
861        assert!(
862            booked.is_due(
863                jiff::Timestamp::now()
864                    .checked_add(jiff::SignedDuration::from_hours(3))
865                    .expect("in range")
866            )
867        );
868    }
869
870    #[test]
871    fn a_wait_that_is_not_a_length_of_time_is_refused_with_an_example() {
872        // An agent given "until the review lands" has to be told what shape the answer takes,
873        // not merely that it was wrong.
874        let fixture = Fixture::new("wait-bad");
875
876        let error = fixture
877            .runtime
878            .wait(&session("analyst", 3), "when the review lands", "x")
879            .expect_err("not a duration");
880
881        assert!(matches!(error, ToolError::BadArguments { .. }));
882        assert!(error.to_string().contains("2h"), "{error}");
883        assert!(fixture.booked().is_empty(), "nothing may be booked");
884    }
885
886    #[test]
887    fn every_unit_a_schedule_understands_works_here_too() {
888        // Same vocabulary as a pipeline's `every`. An operator who wrote `every = "2h"` should not
889        // have to learn a second way to say two hours.
890        for (text, seconds) in [("45s", 45), ("30m", 1_800), ("6h", 21_600), ("3d", 259_200)] {
891            assert_eq!(parse_wait(text), Some(seconds), "{text}");
892        }
893
894        assert_eq!(parse_wait("2 weeks"), None);
895        assert_eq!(parse_wait(""), None);
896    }
897
898    #[test]
899    fn a_learning_nobody_has_proposed_before_is_taken_up() {
900        let fixture = Fixture::new("learn");
901
902        let answer = fixture
903            .runtime
904            .learn(&session("analyst", 3), "The e2e suite needs the VPN.")
905            .expect("a first proposal is taken");
906
907        assert!(answer.contains("Noted"), "{answer}");
908        assert_eq!(fixture.learnings().len(), 1);
909    }
910
911    #[test]
912    fn repeating_advice_you_were_already_given_is_not_evidence() {
913        // Counting an echo would let a single fluke confirm itself in three runs.
914        let fixture = Fixture::new("learn-echo");
915        let who = session("analyst", 3);
916
917        fixture
918            .runtime
919            .learn(&who, "The e2e suite needs the VPN.")
920            .expect("taken");
921        let answer = fixture
922            .runtime
923            .learn(&who, "The e2e suite needs the VPN.")
924            .expect("answered");
925
926        assert!(answer.contains("not evidence"), "{answer}");
927        assert_eq!(
928            fixture.learnings().len(),
929            1,
930            "an echo must not become a second learning"
931        );
932    }
933
934    #[test]
935    fn an_empty_learning_is_refused_with_what_one_looks_like() {
936        let fixture = Fixture::new("learn-empty");
937
938        let error = fixture
939            .runtime
940            .learn(&session("analyst", 3), "   ")
941            .expect_err("not a learning");
942
943        assert!(matches!(error, ToolError::BadArguments { .. }));
944        assert!(
945            error.to_string().contains("one or two sentences"),
946            "{error}"
947        );
948    }
949
950    #[test]
951    fn a_learning_a_human_rejected_is_not_reopened_by_repetition() {
952        // Otherwise an agent overturns a decision by saying it again.
953        let fixture = Fixture::new("learn-refused");
954        let who = session("analyst", 3);
955
956        fixture
957            .runtime
958            .learn(&who, "Skip the tests.")
959            .expect("taken");
960
961        let id = fixture
962            .learnings()
963            .all()
964            .next()
965            .expect("one learning")
966            .id
967            .clone();
968        {
969            let mut held = fixture.learnings.lock().expect("not poisoned");
970            held.reject(&id, jiff::Timestamp::now());
971        }
972
973        let error = fixture
974            .runtime
975            .learn(&who, "Skip the tests.")
976            .expect_err("rejected stays rejected");
977
978        assert!(matches!(error, ToolError::Refused { .. }));
979        assert!(error.to_string().contains("report"), "{error}");
980    }
981
982    #[test]
983    fn the_logbook_records_who_wrote_each_entry() {
984        // It is shared, so an entry nobody can attribute is one nobody can follow up or correct.
985        let fixture = Fixture::new("logbook");
986
987        fixture
988            .runtime
989            .logbook_append(&session("analyst", 3), "The staging database was rebuilt.")
990            .expect("writes");
991
992        let written = std::fs::read_to_string(fixture.dir.join("logbook.md")).expect("a logbook");
993        assert!(written.contains("analyst"), "{written}");
994        assert!(
995            written.contains("staging database was rebuilt"),
996            "{written}"
997        );
998    }
999
1000    #[test]
1001    fn the_logbook_accumulates_rather_than_replacing() {
1002        let fixture = Fixture::new("logbook-append");
1003        let who = session("analyst", 3);
1004
1005        fixture
1006            .runtime
1007            .logbook_append(&who, "first")
1008            .expect("writes");
1009        fixture
1010            .runtime
1011            .logbook_append(&who, "second")
1012            .expect("writes");
1013
1014        let written = std::fs::read_to_string(fixture.dir.join("logbook.md")).expect("a logbook");
1015        assert!(written.contains("first"), "{written}");
1016        assert!(written.contains("second"), "{written}");
1017    }
1018
1019    #[test]
1020    fn an_empty_logbook_entry_is_refused() {
1021        let fixture = Fixture::new("logbook-empty");
1022
1023        let error = fixture
1024            .runtime
1025            .logbook_append(&session("analyst", 3), "  \n ")
1026            .expect_err("noise");
1027
1028        assert!(matches!(error, ToolError::BadArguments { .. }), "{error}");
1029    }
1030
1031    #[test]
1032    fn memory_survives_from_one_run_to_the_next() {
1033        let fixture = Fixture::new("memory");
1034
1035        let first = session("analyst", 3);
1036        fixture
1037            .runtime
1038            .memory_write(&first, "The e2e suite needs the VPN.")
1039            .expect("writes");
1040
1041        // A different run of the same agent: runs are fresh, memory is not.
1042        let second = session("analyst", 3);
1043        let read = fixture.runtime.memory_read(&second).expect("reads");
1044
1045        assert!(read.contains("needs the VPN"), "{read}");
1046    }
1047
1048    #[test]
1049    fn a_first_run_reading_empty_memory_is_told_so_rather_than_failing() {
1050        let fixture = Fixture::new("firstrun");
1051
1052        let read = fixture
1053            .runtime
1054            .memory_read(&session("analyst", 3))
1055            .expect("an empty memory is not a failure");
1056
1057        assert!(read.contains("nothing"), "{read}");
1058    }
1059
1060    #[test]
1061    fn memory_accumulates_rather_than_replacing() {
1062        let fixture = Fixture::new("accumulate");
1063        let who = session("analyst", 3);
1064
1065        fixture
1066            .runtime
1067            .memory_write(&who, "first thing")
1068            .expect("writes");
1069        fixture
1070            .runtime
1071            .memory_write(&who, "second thing")
1072            .expect("writes");
1073
1074        let read = fixture.runtime.memory_read(&who).expect("reads");
1075        assert!(read.contains("first thing"), "{read}");
1076        assert!(read.contains("second thing"), "{read}");
1077    }
1078
1079    #[test]
1080    fn a_report_is_written_where_it_can_be_found_afterwards() {
1081        let fixture = Fixture::new("report");
1082        let who = session("analyst", 3);
1083
1084        fixture
1085            .runtime
1086            .report(&who, "Found the cause", "It was the cache all along.")
1087            .expect("writes");
1088
1089        let written = std::fs::read_to_string(fixture.dir.join("analyst").join("reports.jsonl"))
1090            .expect("a report file");
1091        assert!(written.contains("Found the cause"), "{written}");
1092    }
1093
1094    #[test]
1095    fn a_chain_is_created_once_and_reused() {
1096        let fixture = Fixture::new("chains");
1097        let chains = Chains::new();
1098        let id = ItineraryId::generate();
1099
1100        chains
1101            .with(&id, &fixture.defaults, |chain| chain.debit_fuel(1.0))
1102            .expect("locks");
1103        let remaining = chains
1104            .with(&id, &fixture.defaults, |chain| chain.fuel_remaining_usd())
1105            .expect("locks");
1106
1107        assert!(
1108            remaining < fixture.defaults.fuel_usd,
1109            "the debit must have persisted across lookups"
1110        );
1111        assert_eq!(chains.count(), 1, "one chain, not two");
1112    }
1113}