layover_core/prompt.rs
1//! Dynamic prompt composition.
2//!
3//! An agent's standing instructions may live in a file rather than inline in `layover.toml`, and
4//! that file may pull in others conditionally:
5//!
6//! ```text
7//! You are the tester. Run the project's verification command.
8//!
9//! @include(run_e2e) tester-e2e.md
10//! @include(!run_e2e) tester-local-only.md
11//! ```
12//!
13//! The conditions are the boolean flags a [`crate::pipeline::Pipeline`] declares and a trigger
14//! supplies, so one factory definition serves several situations without duplicated prompts.
15//!
16//! # What this does not do
17//!
18//! Resolution produces the agent's *standing instructions* and nothing else. How those combine
19//! with the flight body, the agent's memory, its learnings and any handover is
20//! [`crate::payload`]'s job, and the order is settled there. Do not assemble a payload here.
21
22use std::collections::BTreeSet;
23use std::path::{Component, Path, PathBuf};
24
25use crate::pipeline::Flags;
26
27/// How deep `@include` may nest.
28///
29/// Deep nesting makes a prompt impossible to reason about, and the limit doubles as a backstop for
30/// any cycle the explicit detection somehow misses.
31pub const MAX_INCLUDE_DEPTH: usize = 8;
32
33/// How many files one prompt may expand to in total.
34///
35/// Cycle detection tracks ancestors, so a diamond — two files that both include a third — is legal
36/// and expands more than once. That is fine in moderation and pathological in bulk, and validation
37/// walks *both* branches of every condition, so it sees the worst case even when a run would not.
38pub const MAX_INCLUDE_EXPANSIONS: usize = 1_000;
39
40/// Somewhere prompt files can be read from.
41///
42/// The indirection exists so prompt composition can be tested without touching a filesystem, and
43/// so the Tower can later serve prompts from somewhere other than a directory.
44pub trait PromptSource {
45 /// Reads the prompt file at `path`, which is always relative to the source's root.
46 ///
47 /// # Errors
48 ///
49 /// Returns [`PromptError::Missing`] when there is no such file, or [`PromptError::Unreadable`]
50 /// when it exists but cannot be read.
51 fn read(&self, path: &Path) -> Result<String, PromptError>;
52}
53
54/// A prompt source backed by a directory on disk.
55///
56/// Paths are confined to the root: an absolute path, or one that climbs out with `..`, is refused
57/// before the filesystem is touched. Prompt files are ordinary repository content and a factory
58/// definition should not be able to read arbitrary files by asking nicely.
59#[derive(Debug, Clone)]
60pub struct PromptDir {
61 root: PathBuf,
62}
63
64impl PromptDir {
65 /// Creates a source rooted at `root`.
66 #[must_use]
67 pub fn new(root: impl Into<PathBuf>) -> Self {
68 Self { root: root.into() }
69 }
70
71 /// Returns the root directory.
72 #[must_use]
73 pub fn root(&self) -> &Path {
74 &self.root
75 }
76}
77
78impl PromptSource for PromptDir {
79 fn read(&self, path: &Path) -> Result<String, PromptError> {
80 let relative = confine(path)?;
81 let full = self.root.join(&relative);
82
83 // Lexical confinement handles `..`, absolute paths and UNC. It does not handle a symlink
84 // *inside* the prompt directory pointing anywhere at all — the path is clean, the target
85 // is not. So the resolved path is compared against the resolved root, which is the only
86 // check that can see through a link.
87 //
88 // Today prompt files are reviewed repository content and anyone who can plant a symlink
89 // can also set `runners.*.command`, so this is not yet a boundary. It becomes one the
90 // moment agents write their own prompts, and doing it then means doing it under pressure.
91 if let Some(escape) = self.escapes(&full) {
92 return Err(PromptError::Escapes { path: escape });
93 }
94
95 match std::fs::read_to_string(&full) {
96 Ok(text) => Ok(text),
97 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
98 Err(PromptError::Missing { path: relative })
99 }
100 Err(error) => Err(PromptError::Unreadable {
101 path: relative,
102 reason: error.to_string(),
103 }),
104 }
105 }
106}
107
108impl PromptDir {
109 /// Whether `full` resolves to somewhere outside the prompt root.
110 ///
111 /// Returns `None` when it is inside, or when the question cannot be answered — a file that
112 /// does not exist cannot be canonicalised, and refusing it here would turn every missing
113 /// prompt into a security error rather than the "no such file" it actually is. The read that
114 /// follows reports it properly.
115 fn escapes(&self, full: &Path) -> Option<PathBuf> {
116 let resolved = std::fs::canonicalize(full).ok()?;
117 let root = std::fs::canonicalize(&self.root).ok()?;
118
119 (!resolved.starts_with(&root)).then(|| {
120 // Reported as the path that was *asked for* rather than where it led. Printing the
121 // resolved target would helpfully tell an attacker what exists outside the sandbox.
122 full.strip_prefix(&self.root).unwrap_or(full).to_path_buf()
123 })
124 }
125}
126
127/// A prompt source held in memory, keyed by relative path.
128#[derive(Debug, Clone, Default)]
129pub struct PromptMap {
130 files: std::collections::BTreeMap<PathBuf, String>,
131}
132
133impl PromptMap {
134 /// Creates an empty source.
135 #[must_use]
136 pub fn new() -> Self {
137 Self::default()
138 }
139
140 /// Adds a file.
141 #[must_use]
142 pub fn with(mut self, path: impl Into<PathBuf>, text: impl Into<String>) -> Self {
143 self.files.insert(path.into(), text.into());
144 self
145 }
146}
147
148impl PromptSource for PromptMap {
149 fn read(&self, path: &Path) -> Result<String, PromptError> {
150 let relative = confine(path)?;
151 self.files
152 .get(&relative)
153 .cloned()
154 .ok_or(PromptError::Missing { path: relative })
155 }
156}
157
158/// Resolves `entry` into finished prompt text, following `@include` directives.
159///
160/// # Errors
161///
162/// Returns a [`PromptError`] when a directive is malformed, names a flag that `flags` does not
163/// declare, points at a missing file, escapes the prompt root, or forms a cycle.
164pub fn resolve(
165 source: &dyn PromptSource,
166 entry: impl AsRef<Path>,
167 flags: &Flags,
168) -> Result<String, PromptError> {
169 let mut stack = Vec::new();
170 let mut output = String::new();
171 expand(
172 source,
173 &confine(entry.as_ref())?,
174 flags,
175 &mut stack,
176 &mut output,
177 )?;
178 Ok(output)
179}
180
181/// Returns every flag name `entry` and its includes mention, ignoring their values.
182///
183/// This is what lets load-time validation catch a prompt referring to a flag no pipeline declares,
184/// before a run discovers it. Conditions are not evaluated, so every branch is walked — a flag
185/// behind a condition that is false today still has to be declared.
186///
187/// It applies exactly the rules [`resolve`] does: the same cycle detection, the same
188/// [`MAX_INCLUDE_DEPTH`], and the same confinement. Anything this accepts, `resolve` can compose;
189/// anything it rejects would have failed at run time instead, which is the whole point of
190/// checking.
191///
192/// # Errors
193///
194/// Returns a [`PromptError`] when a directive is malformed, points at a missing file, escapes the
195/// prompt root, forms a cycle, nests too deeply, or expands past [`MAX_INCLUDE_EXPANSIONS`].
196pub fn referenced_flags(
197 source: &dyn PromptSource,
198 entry: impl AsRef<Path>,
199) -> Result<BTreeSet<String>, PromptError> {
200 let mut found = BTreeSet::new();
201 let mut stack = Vec::new();
202 let mut budget = MAX_INCLUDE_EXPANSIONS;
203
204 walk(
205 source,
206 &confine(entry.as_ref())?,
207 &mut stack,
208 &mut budget,
209 &mut found,
210 )?;
211
212 Ok(found)
213}
214
215/// Walks every branch of an include graph, collecting flag names.
216fn walk(
217 source: &dyn PromptSource,
218 path: &Path,
219 stack: &mut Vec<PathBuf>,
220 budget: &mut usize,
221 found: &mut BTreeSet<String>,
222) -> Result<(), PromptError> {
223 if stack.len() >= MAX_INCLUDE_DEPTH {
224 return Err(PromptError::TooDeep {
225 path: path.to_path_buf(),
226 limit: MAX_INCLUDE_DEPTH,
227 });
228 }
229 if stack.iter().any(|seen| seen == path) {
230 return Err(PromptError::Cycle {
231 path: path.to_path_buf(),
232 });
233 }
234 // Both branches of every condition are walked, so a wide include graph can expand far more
235 // than it would at run time. Bound the work rather than letting validation hang.
236 *budget = budget.checked_sub(1).ok_or_else(|| PromptError::TooWide {
237 path: path.to_path_buf(),
238 limit: MAX_INCLUDE_EXPANSIONS,
239 })?;
240
241 let text = source.read(path)?;
242 stack.push(path.to_path_buf());
243
244 for (number, line) in text.lines().enumerate() {
245 let Some(directive) = Directive::parse(line, path, number + 1)? else {
246 continue;
247 };
248 if let Some(condition) = &directive.condition {
249 found.insert(condition.flag.clone());
250 }
251 let target = confine(&resolve_relative(path, &directive.path))?;
252 walk(source, &target, stack, budget, found)?;
253 }
254
255 stack.pop();
256 Ok(())
257}
258
259fn expand(
260 source: &dyn PromptSource,
261 path: &Path,
262 flags: &Flags,
263 stack: &mut Vec<PathBuf>,
264 output: &mut String,
265) -> Result<(), PromptError> {
266 if stack.len() >= MAX_INCLUDE_DEPTH {
267 return Err(PromptError::TooDeep {
268 path: path.to_path_buf(),
269 limit: MAX_INCLUDE_DEPTH,
270 });
271 }
272 if stack.iter().any(|seen| seen == path) {
273 return Err(PromptError::Cycle {
274 path: path.to_path_buf(),
275 });
276 }
277
278 let text = source.read(path)?;
279 stack.push(path.to_path_buf());
280
281 for (number, line) in text.lines().enumerate() {
282 match Directive::parse(line, path, number + 1)? {
283 None => {
284 output.push_str(line);
285 output.push('\n');
286 }
287 Some(directive) => {
288 let include = match &directive.condition {
289 None => true,
290 Some(condition) => {
291 let value =
292 flags
293 .get(&condition.flag)
294 .ok_or_else(|| PromptError::UnknownFlag {
295 flag: condition.flag.clone(),
296 path: path.to_path_buf(),
297 line: number + 1,
298 })?;
299 value != condition.negated
300 }
301 };
302
303 if include {
304 let target = confine(&resolve_relative(path, &directive.path))?;
305 expand(source, &target, flags, stack, output)?;
306 }
307 }
308 }
309 }
310
311 stack.pop();
312 Ok(())
313}
314
315/// A parsed `@include` line.
316#[derive(Debug, Clone, PartialEq, Eq)]
317struct Directive {
318 condition: Option<Condition>,
319 path: PathBuf,
320}
321
322/// The `(flag)` or `(!flag)` part of a directive.
323#[derive(Debug, Clone, PartialEq, Eq)]
324struct Condition {
325 flag: String,
326 negated: bool,
327}
328
329impl Directive {
330 /// Parses one line, returning `None` when it is ordinary prompt text.
331 fn parse(line: &str, path: &Path, number: usize) -> Result<Option<Self>, PromptError> {
332 let trimmed = line.trim();
333 let Some(rest) = trimmed.strip_prefix("@include") else {
334 return Ok(None);
335 };
336
337 let malformed = || PromptError::MalformedDirective {
338 path: path.to_path_buf(),
339 line: number,
340 text: trimmed.to_owned(),
341 };
342
343 let (condition, remainder) = if let Some(after) = rest.strip_prefix('(') {
344 let (inside, after) = after.split_once(')').ok_or_else(malformed)?;
345 (Some(parse_condition(inside).ok_or_else(malformed)?), after)
346 } else if rest.starts_with(char::is_whitespace) {
347 (None, rest)
348 } else {
349 // `@includes foo` or `@include(` — close enough to a directive to be a typo rather
350 // than prose, so refuse instead of silently treating it as text.
351 return Err(malformed());
352 };
353
354 let target = remainder.trim().trim_matches('"').trim();
355 if target.is_empty() {
356 return Err(malformed());
357 }
358
359 Ok(Some(Self {
360 condition,
361 path: PathBuf::from(target),
362 }))
363 }
364}
365
366fn parse_condition(inside: &str) -> Option<Condition> {
367 let trimmed = inside.trim();
368 let (negated, name) = match trimmed.strip_prefix('!') {
369 Some(rest) => (true, rest.trim()),
370 None => (false, trimmed),
371 };
372
373 let mut chars = name.chars();
374 let first = chars.next()?;
375 if !(first.is_ascii_alphabetic() || first == '_') {
376 return None;
377 }
378 if !chars.all(|c| c.is_ascii_alphanumeric() || c == '_') {
379 return None;
380 }
381
382 Some(Condition {
383 flag: name.to_owned(),
384 negated,
385 })
386}
387
388/// Resolves an included path against the directory of the file that included it.
389fn resolve_relative(including: &Path, target: &Path) -> PathBuf {
390 match including.parent() {
391 Some(parent) if !parent.as_os_str().is_empty() => parent.join(target),
392 _ => target.to_path_buf(),
393 }
394}
395
396/// Rejects a path that would leave the prompt root, and normalises it.
397///
398/// `..` is resolved lexically rather than refused outright, so `shared/../common.md` is fine while
399/// `../../etc/passwd` is not. This is deliberately a lexical check: it does not follow symlinks,
400/// so a symlink inside the prompt root still points wherever it points. Prompt files are
401/// repository content under the same review as the rest of the factory definition.
402fn confine(path: &Path) -> Result<PathBuf, PromptError> {
403 let escapes = || PromptError::Escapes {
404 path: path.to_path_buf(),
405 };
406 let mut clean = PathBuf::new();
407
408 for component in path.components() {
409 match component {
410 Component::Normal(part) => clean.push(part),
411 Component::CurDir => {}
412 Component::ParentDir => {
413 if !clean.pop() {
414 return Err(escapes());
415 }
416 }
417 Component::RootDir | Component::Prefix(_) => return Err(escapes()),
418 }
419 }
420
421 if clean.as_os_str().is_empty() {
422 return Err(escapes());
423 }
424
425 Ok(clean)
426}
427
428/// Why a prompt could not be assembled.
429#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
430pub enum PromptError {
431 /// A file referenced by a directive does not exist.
432 #[error("prompt file `{path}` does not exist")]
433 Missing {
434 /// The path that was looked up.
435 path: PathBuf,
436 },
437 /// A file exists but could not be read.
438 #[error("prompt file `{path}` could not be read: {reason}")]
439 Unreadable {
440 /// The path that was looked up.
441 path: PathBuf,
442 /// Why the read failed.
443 reason: String,
444 },
445 /// A path pointed outside the prompt root.
446 #[error("prompt path `{path}` leaves the prompt directory")]
447 Escapes {
448 /// The offending path.
449 path: PathBuf,
450 },
451 /// A line began with `@include` but was not a usable directive.
452 #[error("`{path}` line {line}: could not read `{text}` as an @include directive")]
453 MalformedDirective {
454 /// The file the line was in.
455 path: PathBuf,
456 /// One-based line number.
457 line: usize,
458 /// The offending line.
459 text: String,
460 },
461 /// A directive named a flag no pipeline declares.
462 #[error("`{path}` line {line}: flag `{flag}` is not declared by any pipeline")]
463 UnknownFlag {
464 /// The flag that was referenced.
465 flag: String,
466 /// The file the line was in.
467 path: PathBuf,
468 /// One-based line number.
469 line: usize,
470 },
471 /// A file includes itself, directly or through others.
472 #[error("prompt file `{path}` includes itself")]
473 Cycle {
474 /// The file that closed the loop.
475 path: PathBuf,
476 },
477 /// Includes nested further than [`MAX_INCLUDE_DEPTH`].
478 #[error("prompt file `{path}` nests includes more than {limit} deep")]
479 TooDeep {
480 /// The file that breached the limit.
481 path: PathBuf,
482 /// The limit.
483 limit: usize,
484 },
485 /// An include graph that expands to more than [`MAX_INCLUDE_EXPANSIONS`] files.
486 #[error("prompt composition reached `{path}` after more than {limit} expansions")]
487 TooWide {
488 /// The file that breached the limit.
489 path: PathBuf,
490 /// The limit.
491 limit: usize,
492 },
493}