Skip to main content

layover_core/
agent.rs

1//! Agent identity and configuration.
2//!
3//! An agent's *name* is the key it is declared under in `layover.toml`. Everything else here
4//! describes what the agent is for, which matters more than it looks: `layover_peers()` hands
5//! these descriptions to a running agent so it can decide where to route work. An agent with no
6//! description is a name the mesh cannot reason about.
7
8use std::collections::BTreeMap;
9use std::path::PathBuf;
10
11use serde::{Deserialize, Serialize};
12use std::fmt;
13
14use crate::handover::RecoveryPolicy;
15use crate::mcp::McpServer;
16
17/// The name of an agent, as written in `layover.toml`.
18///
19/// This is the table key — `[agents.analyst]` declares an agent named `analyst` — and it is what
20/// routes, joins and flight envelopes refer to.
21#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
22#[serde(transparent)]
23pub struct AgentName(String);
24
25impl AgentName {
26    /// Creates an agent name.
27    #[must_use]
28    pub fn new(name: impl Into<String>) -> Self {
29        Self(name.into())
30    }
31
32    /// Returns the name as a string slice.
33    #[must_use]
34    pub fn as_str(&self) -> &str {
35        &self.0
36    }
37}
38
39impl fmt::Display for AgentName {
40    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41        f.write_str(&self.0)
42    }
43}
44
45impl From<&str> for AgentName {
46    fn from(value: &str) -> Self {
47        Self(value.to_owned())
48    }
49}
50
51/// Whether an agent may write to the shared workspace.
52///
53/// Read-only agents are given a worktree snapshot rather than the live tree, which is real
54/// enforcement rather than an advisory flag.
55#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
56#[serde(rename_all = "kebab-case")]
57pub enum Access {
58    /// Receives a read-only snapshot of the workspace.
59    ReadOnly,
60    /// Works directly in the shared workspace.
61    #[default]
62    ReadWrite,
63}
64
65/// Where an agent's standing instructions come from.
66///
67/// Exactly one of the two forms must be given. Inline prompts are convenient for short
68/// instructions; a prompt file is what allows conditional composition — see [`crate::prompt`].
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub enum PromptSpec {
71    /// The prompt text, written directly in `layover.toml`.
72    Inline(String),
73    /// A path to a prompt file, resolved relative to the prompt root.
74    File(PathBuf),
75}
76
77/// A configured agent.
78#[derive(Debug, Clone, Deserialize)]
79#[serde(deny_unknown_fields)]
80pub struct Agent {
81    /// One line saying what this agent is.
82    ///
83    /// Handed to peers by `layover_peers()`, so a sending agent can tell who is worth talking to
84    /// without the topology being hard-coded into its prompt.
85    #[serde(default)]
86    pub description: Option<String>,
87    /// A longer statement of what the agent is for and when to route work to it.
88    #[serde(default)]
89    pub purpose: Option<String>,
90    /// Runner to invoke; falls back to [`crate::config::Defaults::runner`].
91    #[serde(default)]
92    pub runner: Option<String>,
93    /// Model identifier passed to the runner.
94    #[serde(default)]
95    pub model: Option<String>,
96    /// The agent's standing instructions, written inline.
97    ///
98    /// Mutually exclusive with [`Agent::prompt_file`]; use [`Agent::prompt_spec`] rather than
99    /// reading either field directly.
100    #[serde(default)]
101    pub prompt: Option<String>,
102    /// The agent's standing instructions, read from a file that may compose others.
103    #[serde(default)]
104    pub prompt_file: Option<PathBuf>,
105    /// Whether the agent may write to the shared workspace.
106    #[serde(default)]
107    pub access: Access,
108    /// Whether a human may send flights directly to this agent.
109    ///
110    /// A named [`crate::pipeline::Pipeline`] also makes its entry agent reachable. This flag is
111    /// the lower-level permission, useful for an agent you want to be able to poke by hand
112    /// without declaring a trigger for it.
113    #[serde(default)]
114    pub entry: bool,
115    /// Whether the agent is pinned resident rather than transient.
116    #[serde(default)]
117    pub resident: bool,
118    /// Per-agent Fuel override, applied when an itinerary starts at this agent.
119    #[serde(default)]
120    pub fuel_usd: Option<f64>,
121    /// Names of environment variables forwarded to this agent's own CLI.
122    ///
123    /// The agent CLI needs credentials of its own before it can do anything: `copilot` wants a
124    /// `GITHUB_TOKEN`, `claude` an `ANTHROPIC_API_KEY`. Those are separate from the ones its MCP
125    /// servers need, which are declared per server in [`crate::mcp::McpServer::env_from`], and
126    /// they are separate on purpose — the agent that publishes releases holds the publishing
127    /// token, and the one that reads telemetry does not.
128    ///
129    /// Only names appear here. The Tower reads each value from its own environment at spawn time,
130    /// so `layover.toml` stays a file you can commit. A name that is not set refuses the run
131    /// rather than starting a CLI that will fail to authenticate several seconds later.
132    ///
133    /// [`crate::config::Defaults::env_from`] covers the common case of every agent using the same
134    /// CLI credential; the two are combined rather than overriding one another.
135    #[serde(default)]
136    pub env_from: Vec<String>,
137    /// MCP servers this agent may reach, keyed by name.
138    ///
139    /// Layover's own server is always wired up; these are the ones the agent needs to do its job
140    /// — a telemetry agent's Kusto endpoint, a publisher's issue tracker. Secrets never appear
141    /// here: see [`crate::mcp`].
142    #[serde(default)]
143    pub mcp: BTreeMap<String, McpServer>,
144    /// Whether interrupted work for this agent may be restarted without asking.
145    ///
146    /// The question is not whether the Tower *can* restart it but whether doing the work twice
147    /// is safe. An agent that opened a pull request would open a second.
148    #[serde(default)]
149    pub recovery: RecoveryPolicy,
150    /// Directory this agent works in, overriding `[layover] work_dir`.
151    ///
152    /// For an agent whose job is somewhere else entirely — mining telemetry from a different
153    /// checkout, say. Resolved relative to the configuration file.
154    #[serde(default)]
155    pub work_dir: Option<PathBuf>,
156}
157
158impl Agent {
159    /// Returns where this agent's prompt comes from.
160    ///
161    /// # Errors
162    ///
163    /// Returns [`PromptSpecError`] when neither form is given or when both are.
164    pub fn prompt_spec(&self) -> Result<PromptSpec, PromptSpecError> {
165        match (self.prompt.as_ref(), self.prompt_file.as_ref()) {
166            (Some(text), None) => Ok(PromptSpec::Inline(text.clone())),
167            (None, Some(path)) => Ok(PromptSpec::File(path.clone())),
168            (Some(_), Some(_)) => Err(PromptSpecError::Both),
169            (None, None) => Err(PromptSpecError::Neither),
170        }
171    }
172
173    /// Returns the one-line description, or a placeholder when none was configured.
174    #[must_use]
175    pub fn description_or_placeholder(&self) -> &str {
176        self.description
177            .as_deref()
178            .unwrap_or("(no description configured)")
179    }
180}
181
182/// Why an agent's prompt configuration is unusable.
183#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
184pub enum PromptSpecError {
185    /// Neither `prompt` nor `prompt_file` was given.
186    #[error("neither `prompt` nor `prompt_file` is set")]
187    Neither,
188    /// Both forms were given, so which one applies is undefined.
189    #[error("both `prompt` and `prompt_file` are set; exactly one is allowed")]
190    Both,
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    fn agent(body: &str) -> Agent {
198        toml::from_str(body).expect("agent parses")
199    }
200
201    #[test]
202    fn a_name_round_trips_through_display() {
203        let name = AgentName::new("analyst");
204
205        assert_eq!(name.as_str(), "analyst");
206        assert_eq!(name.to_string(), "analyst");
207        assert_eq!(AgentName::from("analyst"), name);
208    }
209
210    #[test]
211    fn an_inline_prompt_is_recognised() {
212        let agent = agent(r#"prompt = "do the thing""#);
213
214        assert_eq!(
215            agent.prompt_spec(),
216            Ok(PromptSpec::Inline("do the thing".to_owned()))
217        );
218    }
219
220    #[test]
221    fn a_prompt_file_is_recognised() {
222        let agent = agent(r#"prompt_file = "prompts/tester.md""#);
223
224        assert_eq!(
225            agent.prompt_spec(),
226            Ok(PromptSpec::File(PathBuf::from("prompts/tester.md")))
227        );
228    }
229
230    #[test]
231    fn giving_both_prompt_forms_is_rejected() {
232        let agent = agent(
233            r#"
234            prompt = "inline"
235            prompt_file = "prompts/tester.md"
236            "#,
237        );
238
239        assert_eq!(agent.prompt_spec(), Err(PromptSpecError::Both));
240    }
241
242    #[test]
243    fn giving_neither_prompt_form_is_rejected() {
244        let agent = agent(r#"description = "does something""#);
245
246        assert_eq!(agent.prompt_spec(), Err(PromptSpecError::Neither));
247    }
248
249    #[test]
250    fn description_and_purpose_are_optional_but_preserved() {
251        let agent = agent(
252            r#"
253            description = "Turns a request into a work item"
254            purpose = "Longer explanation of when to route here."
255            prompt = "analyse"
256            "#,
257        );
258
259        assert_eq!(
260            agent.description.as_deref(),
261            Some("Turns a request into a work item")
262        );
263        assert!(agent.purpose.is_some());
264        assert_eq!(
265            agent.description_or_placeholder(),
266            "Turns a request into a work item"
267        );
268    }
269
270    #[test]
271    fn a_missing_description_falls_back_to_a_placeholder() {
272        let agent = agent(r#"prompt = "analyse""#);
273
274        assert_eq!(
275            agent.description_or_placeholder(),
276            "(no description configured)"
277        );
278    }
279}