Skip to main content

layover_core/
tools.rs

1//! The tools an agent may call, named once.
2//!
3//! # Why the registry is here rather than in the server
4//!
5//! Prompts tell agents to call these tools. Before this existed, the names in prompts, in the
6//! book and in the code disagreed — eleven were documented and none were implemented — because
7//! nothing could check. A name is only useful if the thing answering to it exists.
8//!
9//! Putting the list in the domain crate means `layover validate` can read a prompt, find every
10//! tool it mentions, and refuse a factory that tells an agent to call something that is not there.
11//! That check is worth more than it sounds: an agent instructed to use a tool it does not have
12//! will improvise, and improvising is exactly what a factory is meant not to do unattended.
13//!
14//! # What is deliberately absent
15//!
16//! There is no `layover_spawn`. A `mode = "spawn"` route already opens one itinerary per flight,
17//! and a tool that did the same would be a second permission model over the same graph — two
18//! places to look when asking what an agent is allowed to start, which is one too many.
19
20use std::fmt;
21
22/// A tool an agent can call over MCP.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
24pub enum Tool {
25    /// Send a flight to another agent. The only way work moves.
26    Send,
27    /// Ask who this agent may reach, and what each of them is for.
28    Peers,
29    /// Say what this run concluded.
30    Report,
31    /// Say what is in the way.
32    Help,
33    /// Read this agent's own notes.
34    MemoryRead,
35    /// Add to this agent's own notes.
36    MemoryWrite,
37    /// Propose something future runs of this agent should know.
38    Learn,
39    /// Add to the factory's shared memory.
40    LogbookAppend,
41    /// Ask what this run has left: its Hops, its Fuel.
42    Status,
43    /// Set work down to be picked up later.
44    Wait,
45}
46
47impl Tool {
48    /// Every tool, in a stable order.
49    pub const ALL: [Self; 10] = [
50        Self::Send,
51        Self::Peers,
52        Self::Report,
53        Self::Help,
54        Self::MemoryRead,
55        Self::MemoryWrite,
56        Self::Learn,
57        Self::LogbookAppend,
58        Self::Status,
59        Self::Wait,
60    ];
61
62    /// The name an agent calls it by.
63    #[must_use]
64    pub const fn name(self) -> &'static str {
65        match self {
66            Self::Send => "layover_send",
67            Self::Peers => "layover_peers",
68            Self::Report => "layover_report",
69            Self::Help => "layover_help",
70            Self::MemoryRead => "layover_memory_read",
71            Self::MemoryWrite => "layover_memory_write",
72            Self::Learn => "layover_learn",
73            Self::LogbookAppend => "layover_logbook_append",
74            Self::Status => "layover_status",
75            Self::Wait => "layover_wait",
76        }
77    }
78
79    /// What it is for, written for the agent that will read it.
80    #[must_use]
81    pub const fn description(self) -> &'static str {
82        match self {
83            Self::Send => {
84                "Send work to another agent. This is the only way work moves, and sending is what \
85                 starts the agent you send to -- there is no separate spawn. You may only send to \
86                 agents `layover_peers` lists."
87            }
88            Self::Peers => {
89                "List the agents you may send to and what each one is for. Call this before \
90                 deciding where work goes rather than guessing at names."
91            }
92            Self::Report => {
93                "Say what you concluded. One headline, then the detail. Write it for somebody who \
94                 was not watching and will read only the headline -- this is the account of your \
95                 run that survives."
96            }
97            Self::Help => {
98                "Say that something is in the way and you could not get past it. Use this instead \
99                 of producing a plausible answer you do not believe: a wrong answer nobody flags \
100                 travels downstream, and that is the failure this exists to prevent."
101            }
102            Self::MemoryRead => {
103                "Read your own notes in full. Every run starts fresh, so this is the only thing \
104                 you remember. The most recent part is already in your instructions."
105            }
106            Self::MemoryWrite => {
107                "Add to your own notes, for future runs of you. Nothing else carries over, so \
108                 anything worth remembering has to be written here deliberately."
109            }
110            Self::Learn => {
111                "Propose something future runs of you should know. It applies immediately and \
112                 lapses unless later runs arrive at it independently."
113            }
114            Self::LogbookAppend => {
115                "Add to the factory's shared memory, which every agent can read. For things the \
116                 whole factory needs, not for your own notes."
117            }
118            Self::Status => {
119                "Ask what this chain has left: how many more messages it may send, and how much \
120                 budget remains. Worth checking before fanning out to several agents."
121            }
122            Self::Wait => {
123                "Set this work down and have it picked up later, when something you are waiting \
124                 for has happened. Nothing is kept running in the meantime."
125            }
126        }
127    }
128
129    /// Finds a tool by the name an agent would call.
130    #[must_use]
131    pub fn from_name(name: &str) -> Option<Self> {
132        Self::ALL.into_iter().find(|tool| tool.name() == name)
133    }
134
135    /// Whether this tool changes anything outside the run that called it.
136    ///
137    /// Used to decide what a read-only agent may do: an agent given a worktree snapshot so it
138    /// cannot disturb anyone should not be able to disturb anyone through a tool either.
139    #[must_use]
140    pub const fn writes(self) -> bool {
141        matches!(
142            self,
143            Self::Send | Self::MemoryWrite | Self::LogbookAppend | Self::Learn | Self::Wait
144        )
145    }
146}
147
148impl fmt::Display for Tool {
149    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
150        f.write_str(self.name())
151    }
152}
153
154/// Every `layover_*` name mentioned in `text` that is not a tool.
155///
156/// Used by validation against prompt files. Deliberately anchored on the `layover_` prefix: an
157/// agent told to call `layover_publish` has been told something false, whereas an agent told to
158/// call `git` has been told something this crate knows nothing about.
159#[must_use]
160pub fn unknown_tools_in(text: &str) -> Vec<String> {
161    let mut found = Vec::new();
162
163    for (index, _) in text.match_indices("layover_") {
164        let rest = &text[index..];
165        let end = rest
166            .find(|c: char| !c.is_ascii_alphanumeric() && c != '_')
167            .unwrap_or(rest.len());
168        let name = &rest[..end];
169
170        if Tool::from_name(name).is_none() && !found.iter().any(|seen| seen == name) {
171            found.push(name.to_owned());
172        }
173    }
174
175    found.sort();
176    found
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn every_tool_has_a_distinct_name() {
185        let mut names: Vec<_> = Tool::ALL.iter().map(|tool| tool.name()).collect();
186        names.sort_unstable();
187        let before = names.len();
188        names.dedup();
189
190        assert_eq!(names.len(), before, "two tools answer to the same name");
191    }
192
193    #[test]
194    fn every_name_round_trips() {
195        for tool in Tool::ALL {
196            assert_eq!(Tool::from_name(tool.name()), Some(tool));
197        }
198    }
199
200    #[test]
201    fn every_tool_is_named_for_layover() {
202        // An agent's tool list mixes ours with the CLI's own and any MCP server the factory
203        // declared. A shared prefix is what makes ours identifiable at a glance.
204        for tool in Tool::ALL {
205            assert!(tool.name().starts_with("layover_"), "{}", tool.name());
206        }
207    }
208
209    #[test]
210    fn every_tool_says_what_it_is_for() {
211        // The description is what an agent reads when deciding whether to call it. A tool with a
212        // thin description gets used wrongly or not at all.
213        for tool in Tool::ALL {
214            assert!(
215                tool.description().len() > 60,
216                "`{}` needs a description an agent can act on",
217                tool.name()
218            );
219        }
220    }
221
222    #[test]
223    fn there_is_no_spawn_tool() {
224        // A `mode = "spawn"` route already opens an itinerary per flight. A tool doing the same
225        // would be a second permission model over one graph.
226        assert_eq!(Tool::from_name("layover_spawn"), None);
227    }
228
229    #[test]
230    fn a_prompt_naming_a_tool_that_does_not_exist_is_caught() {
231        let prompt = "Investigate, then call layover_publish to ship it.";
232        assert_eq!(unknown_tools_in(prompt), vec!["layover_publish"]);
233    }
234
235    #[test]
236    fn a_prompt_naming_real_tools_is_clean() {
237        let prompt = "Call layover_peers, then layover_send. If stuck, layover_help.";
238        assert!(unknown_tools_in(prompt).is_empty());
239    }
240
241    #[test]
242    fn the_same_wrong_name_twice_is_reported_once() {
243        let prompt = "use layover_ship. then layover_ship again.";
244        assert_eq!(unknown_tools_in(prompt), vec!["layover_ship"]);
245    }
246
247    #[test]
248    fn a_tool_name_followed_by_punctuation_is_still_recognised() {
249        for prompt in [
250            "call layover_send(...)",
251            "call `layover_send`",
252            "call layover_send.",
253            "call layover_send, then wait",
254        ] {
255            assert!(unknown_tools_in(prompt).is_empty(), "{prompt}");
256        }
257    }
258
259    #[test]
260    fn a_read_only_agent_can_be_told_which_tools_would_change_things() {
261        assert!(Tool::Send.writes());
262        assert!(Tool::MemoryWrite.writes());
263        assert!(!Tool::Peers.writes());
264        assert!(!Tool::MemoryRead.writes());
265        assert!(
266            !Tool::Report.writes(),
267            "a report is an account of a run, not an effect on anything else"
268        );
269    }
270}