119 lines
4.6 KiB
Rust
119 lines
4.6 KiB
Rust
|
|
//! Project-local agent prompt layer.
|
||
|
|
//!
|
||
|
|
//! Reads `.huskies/AGENT.md` from the project root and appends its content to
|
||
|
|
//! the baked-in agent prompt at spawn time. This lets projects record
|
||
|
|
//! non-obvious facts (directory conventions, known traps, etc.) that every
|
||
|
|
//! agent should know without modifying the shared agent configuration.
|
||
|
|
//!
|
||
|
|
//! Behaviour contract:
|
||
|
|
//! - If the file is missing or empty the caller receives `None`; agents spawn
|
||
|
|
//! normally with no warnings or errors.
|
||
|
|
//! - If the file exists and is non-empty, the content is returned and an
|
||
|
|
//! INFO-level log line is emitted with the file path and byte count.
|
||
|
|
//! - The file is read fresh on every agent spawn — no caching.
|
||
|
|
|
||
|
|
use std::path::Path;
|
||
|
|
|
||
|
|
/// Attempt to load the project-local agent prompt from `.huskies/AGENT.md`.
|
||
|
|
///
|
||
|
|
/// Returns `Some(content)` when the file exists and is non-empty, or `None`
|
||
|
|
/// when the file is absent or empty. Never returns an error; any I/O problem
|
||
|
|
/// is silently treated as "no local prompt".
|
||
|
|
pub fn read_project_local_prompt(project_root: &Path) -> Option<String> {
|
||
|
|
let path = project_root.join(".huskies/AGENT.md");
|
||
|
|
let content = std::fs::read_to_string(&path).ok()?;
|
||
|
|
let trimmed = content.trim();
|
||
|
|
if trimmed.is_empty() {
|
||
|
|
return None;
|
||
|
|
}
|
||
|
|
crate::slog!(
|
||
|
|
"[agents] project-local prompt loaded: {} ({} bytes)",
|
||
|
|
path.display(),
|
||
|
|
trimmed.len()
|
||
|
|
);
|
||
|
|
Some(trimmed.to_string())
|
||
|
|
}
|
||
|
|
|
||
|
|
#[cfg(test)]
|
||
|
|
mod tests {
|
||
|
|
use super::*;
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn returns_none_when_file_absent() {
|
||
|
|
let tmp = tempfile::tempdir().unwrap();
|
||
|
|
let result = read_project_local_prompt(tmp.path());
|
||
|
|
assert!(result.is_none(), "missing file must return None");
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn returns_none_when_file_empty() {
|
||
|
|
let tmp = tempfile::tempdir().unwrap();
|
||
|
|
let huskies_dir = tmp.path().join(".huskies");
|
||
|
|
std::fs::create_dir_all(&huskies_dir).unwrap();
|
||
|
|
std::fs::write(huskies_dir.join("AGENT.md"), "").unwrap();
|
||
|
|
let result = read_project_local_prompt(tmp.path());
|
||
|
|
assert!(result.is_none(), "empty file must return None");
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn returns_none_when_file_whitespace_only() {
|
||
|
|
let tmp = tempfile::tempdir().unwrap();
|
||
|
|
let huskies_dir = tmp.path().join(".huskies");
|
||
|
|
std::fs::create_dir_all(&huskies_dir).unwrap();
|
||
|
|
std::fs::write(huskies_dir.join("AGENT.md"), " \n\n ").unwrap();
|
||
|
|
let result = read_project_local_prompt(tmp.path());
|
||
|
|
assert!(result.is_none(), "whitespace-only file must return None");
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn returns_content_when_file_non_empty() {
|
||
|
|
let tmp = tempfile::tempdir().unwrap();
|
||
|
|
let huskies_dir = tmp.path().join(".huskies");
|
||
|
|
std::fs::create_dir_all(&huskies_dir).unwrap();
|
||
|
|
let marker = "DISTINCTIVE_MARKER_XYZ42";
|
||
|
|
std::fs::write(huskies_dir.join("AGENT.md"), format!("# Hints\n{marker}\n")).unwrap();
|
||
|
|
let result = read_project_local_prompt(tmp.path());
|
||
|
|
assert!(result.is_some(), "non-empty file must return Some");
|
||
|
|
let content = result.unwrap();
|
||
|
|
assert!(
|
||
|
|
content.contains(marker),
|
||
|
|
"returned content must include the marker: {content}"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn appended_to_prompt_integration() {
|
||
|
|
// Simulates the start.rs usage: marker appears in the constructed
|
||
|
|
// system prompt when the file is present, absent when it is not.
|
||
|
|
let tmp_with = tempfile::tempdir().unwrap();
|
||
|
|
let huskies_dir = tmp_with.path().join(".huskies");
|
||
|
|
std::fs::create_dir_all(&huskies_dir).unwrap();
|
||
|
|
let marker = "INTEGRATION_MARKER_601";
|
||
|
|
std::fs::write(huskies_dir.join("AGENT.md"), marker).unwrap();
|
||
|
|
|
||
|
|
let base_prompt = "You are a coder agent.".to_string();
|
||
|
|
let local = read_project_local_prompt(tmp_with.path());
|
||
|
|
let effective = match local {
|
||
|
|
Some(ref extra) => format!("{base_prompt}\n\n{extra}"),
|
||
|
|
None => base_prompt.clone(),
|
||
|
|
};
|
||
|
|
assert!(
|
||
|
|
effective.contains(marker),
|
||
|
|
"marker must appear in effective prompt when file present: {effective}"
|
||
|
|
);
|
||
|
|
|
||
|
|
// Without the file
|
||
|
|
let tmp_without = tempfile::tempdir().unwrap();
|
||
|
|
let local2 = read_project_local_prompt(tmp_without.path());
|
||
|
|
assert!(local2.is_none(), "no marker when file absent");
|
||
|
|
let effective2 = match local2 {
|
||
|
|
Some(ref extra) => format!("{base_prompt}\n\n{extra}"),
|
||
|
|
None => base_prompt.clone(),
|
||
|
|
};
|
||
|
|
assert!(
|
||
|
|
!effective2.contains(marker),
|
||
|
|
"marker must NOT appear in effective prompt when file absent: {effective2}"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|