Skip to main content

sloop/worker/
prompt.rs

1//! *How* a worker operates: the prompt text handed to an agent process as
2//! argv when it is spawned.
3//!
4//! Every string here is either compiled into the binary or read from a
5//! committed file the caller names. Nothing in this file learns anything about
6//! the run itself — the facts a prompt is built around arrive as arguments.
7
8use std::fs;
9use std::io;
10use std::path::Path;
11
12use crate::flow::{PANEL_PROMPT_ROOT, Panel};
13
14const WORKER_BOOTSTRAP_PROMPT: &str = include_str!("instructions.md").trim_ascii();
15
16pub const REVIEW_PROMPT_PATH: &str = ".agents/sloop/prompts/review.md";
17
18/// The bootstrap prepended to a panel reviewer's prompt. A reviewer is not the
19/// ticket's worker: it reads, it does not write, and its one job is the report.
20pub const PANEL_REVIEWER_INSTRUCTION: &str = "You are one reviewer on a panel judging the work in this git worktree. Run `sloop brief` to read the assignment under review. Read and run whatever you need, but change nothing. Report your verdict with `sloop verdict pass|fail --reason <text> [--confidence low|medium|high]` exactly once; that call, not your prose, is your report, and finishing without it counts as a fail.";
21
22/// How many lines of a failed stage's output a re-entered stage is shown. Long
23/// enough for a test failure's tail, short enough that the block cannot crowd
24/// the ticket out of the prompt.
25pub const BACKWARD_CONTEXT_LINES: usize = 100;
26
27pub fn compose_worker_prompt(root: &Path) -> Result<String, String> {
28    let path = root.join(".agents/sloop/instructions.md");
29    match fs::read_to_string(&path) {
30        Ok(instructions) => Ok(format!("{WORKER_BOOTSTRAP_PROMPT}\n\n{instructions}")),
31        Err(error) if error.kind() == io::ErrorKind::NotFound => {
32            Ok(WORKER_BOOTSTRAP_PROMPT.to_owned())
33        }
34        Err(error) => Err(format!("cannot read {}: {error}", path.display())),
35    }
36}
37
38/// The prompt every seat is handed, read from the committed file the panel
39/// names. One prompt for the whole panel: reviewers who were asked
40/// different questions do not produce comparable answers, so a quorum over
41/// them would count nothing in particular.
42pub fn panel_prompt(root: &Path, panel: &Panel) -> Result<String, String> {
43    let path = root.join(PANEL_PROMPT_ROOT).join(&panel.prompt);
44    let text = fs::read_to_string(&path)
45        .map_err(|error| format!("cannot read panel prompt {}: {error}", path.display()))?;
46    Ok(format!("{PANEL_REVIEWER_INSTRUCTION}\n\n{text}"))
47}
48
49/// The failure a backward edge jumped on, recovered from the run's persisted
50/// evidence.
51///
52/// A re-entered agent must be told why it is running again or the loop cannot
53/// converge — it would reproduce the same work and fail the same check. The
54/// facts are read back from the stage log and the captured output rather than
55/// carried in memory, so a daemon that resumes mid-loop composes byte-for-byte
56/// the prompt the daemon that took the jump would have.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct FailureContext {
59    pub stage: String,
60    pub attempt: u32,
61    pub reason: String,
62    /// The tail of that execution's captured output, already capped.
63    pub output: String,
64}
65
66/// The "previous attempt failed" block appended to a re-entered agent's
67/// prompt. Delimited on both sides because everything inside it is untrusted
68/// process output that must not read as further instructions.
69pub fn previous_attempt_block(context: &FailureContext) -> String {
70    let mut block = format!(
71        "--- previous attempt failed ---\n\
72         Stage `{}` (attempt {}) failed: {}\n\
73         You are running again to address that failure.",
74        context.stage, context.attempt, context.reason,
75    );
76    if !context.output.is_empty() {
77        block.push_str(&format!(
78            "\n\nThe last {BACKWARD_CONTEXT_LINES} lines of that stage's output:\n{}",
79            context.output,
80        ));
81    }
82    block.push_str("\n--- end previous attempt ---");
83    block
84}
85
86#[cfg(test)]
87mod tests {
88    use std::fs;
89
90    use tempfile::tempdir;
91
92    use super::{
93        FailureContext, WORKER_BOOTSTRAP_PROMPT, compose_worker_prompt, previous_attempt_block,
94    };
95
96    #[test]
97    fn worker_prompt_uses_the_builtin_when_instructions_are_absent() {
98        let root = tempdir().unwrap();
99
100        assert_eq!(
101            compose_worker_prompt(root.path()).unwrap(),
102            WORKER_BOOTSTRAP_PROMPT
103        );
104    }
105
106    #[test]
107    fn worker_prompt_appends_repository_instructions() {
108        let root = tempdir().unwrap();
109        fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
110        fs::write(
111            root.path().join(".agents/sloop/instructions.md"),
112            "Use repository conventions.\n",
113        )
114        .unwrap();
115
116        assert_eq!(
117            compose_worker_prompt(root.path()).unwrap(),
118            format!("{WORKER_BOOTSTRAP_PROMPT}\n\nUse repository conventions.\n")
119        );
120    }
121
122    /// The block is delimited on both sides because everything between the
123    /// markers is untrusted process output.
124    #[test]
125    fn the_previous_attempt_block_names_the_failure_and_fences_its_output() {
126        let block = previous_attempt_block(&FailureContext {
127            stage: "test".into(),
128            attempt: 2,
129            reason: "exit 101".into(),
130            output: "assertion failed\n  left: 1".into(),
131        });
132
133        assert_eq!(
134            block,
135            concat!(
136                "--- previous attempt failed ---\n",
137                "Stage `test` (attempt 2) failed: exit 101\n",
138                "You are running again to address that failure.\n",
139                "\n",
140                "The last 100 lines of that stage's output:\n",
141                "assertion failed\n",
142                "  left: 1\n",
143                "--- end previous attempt ---",
144            )
145        );
146    }
147
148    /// A stage that failed silently still explains itself; an empty output
149    /// section would only be noise.
150    #[test]
151    fn the_previous_attempt_block_omits_an_empty_output_section() {
152        let block = previous_attempt_block(&FailureContext {
153            stage: "review".into(),
154            attempt: 1,
155            reason: "no verdict reported".into(),
156            output: String::new(),
157        });
158
159        assert_eq!(
160            block,
161            concat!(
162                "--- previous attempt failed ---\n",
163                "Stage `review` (attempt 1) failed: no verdict reported\n",
164                "You are running again to address that failure.\n",
165                "--- end previous attempt ---",
166            )
167        );
168    }
169}