Skip to main content

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}