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}