use async_trait::async_trait;
use crate::memory::{MemMessage, MemoryProvider, SessionContext};
use crate::metrics::TurnMetrics;
pub struct AgentsProvider {
enabled: bool,
path_override: Option<String>,
loaded: Vec<(String, String)>,
}
impl AgentsProvider {
pub const MAX_FILE_BYTES: usize = 65_536;
const DIR_CANDIDATES: [&'static str; 2] = ["AGENTS.md", "CLAUDE.md"];
pub fn new(enabled: bool, path_override: Option<String>) -> Self {
Self {
enabled,
path_override,
loaded: Vec::new(),
}
}
fn read_capped(path: &std::path::Path) -> Option<String> {
let raw = std::fs::read_to_string(path).ok()?;
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
if trimmed.len() > Self::MAX_FILE_BYTES {
let mut end = Self::MAX_FILE_BYTES;
while end > 0 && !trimmed.is_char_boundary(end) {
end -= 1;
}
let mut out = trimmed[..end].to_string();
out.push_str("\n\n[... truncated]");
Some(out)
} else {
Some(trimmed.to_string())
}
}
pub fn load(&mut self, workspace: &str) {
self.loaded.clear();
if !self.enabled {
return;
}
let target = match &self.path_override {
Some(p) => {
let p = std::path::Path::new(p);
if p.is_absolute() {
p.to_path_buf()
} else {
std::path::Path::new(workspace).join(p)
}
}
None => std::path::PathBuf::from(workspace),
};
if target.is_file() {
let name = target
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "instructions".to_string());
if let Some(text) = Self::read_capped(&target) {
self.loaded.push((name, text));
}
} else if target.is_dir() {
for candidate in Self::DIR_CANDIDATES {
let path = target.join(candidate);
if let Some(text) = Self::read_capped(&path) {
self.loaded.push((candidate.to_string(), text));
}
}
}
}
}
#[async_trait]
impl MemoryProvider for AgentsProvider {
fn name(&self) -> &str {
"agents"
}
async fn initialize(&mut self, ctx: &SessionContext) -> anyhow::Result<()> {
self.load(&ctx.workspace);
tracing::info!(files = self.loaded.len(), "project instructions loaded");
Ok(())
}
fn system_prompt_block(&self) -> Option<String> {
if self.loaded.is_empty() {
return None;
}
let mut block = String::from(
"# Project instructions\n\n\
Project-specific instructions found in the workspace. Follow them \
unless they conflict with a direct user request.",
);
for (name, contents) in &self.loaded {
block.push_str("\n\n## ");
block.push_str(name);
block.push('\n');
block.push_str(contents);
}
Some(block)
}
fn build_messages(&self, _system_prompt: &str, _new_task: &str) -> Vec<MemMessage> {
Vec::new()
}
async fn sync_turn(&mut self, _user: &str, _assistant: &str, _metrics: &TurnMetrics) {}
}
#[cfg(test)]
mod tests {
use super::*;
fn ctx(workspace: &str) -> SessionContext {
SessionContext {
workspace: workspace.to_string(),
session_id: "s".into(),
}
}
#[tokio::test]
async fn loads_agents_md_only() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("AGENTS.md"), "Use 4 spaces.").unwrap();
let mut p = AgentsProvider::new(true, None);
p.initialize(&ctx(dir.path().to_str().unwrap()))
.await
.unwrap();
let block = p.system_prompt_block().unwrap();
assert!(block.contains("# Project instructions"));
assert!(block.contains("## AGENTS.md"));
assert!(block.contains("Use 4 spaces."));
assert!(!block.contains("## CLAUDE.md"));
}
#[tokio::test]
async fn loads_claude_md_only() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("CLAUDE.md"), "Never push to main.").unwrap();
let mut p = AgentsProvider::new(true, None);
p.initialize(&ctx(dir.path().to_str().unwrap()))
.await
.unwrap();
let block = p.system_prompt_block().unwrap();
assert!(block.contains("## CLAUDE.md"));
assert!(block.contains("Never push to main."));
assert!(!block.contains("## AGENTS.md"));
}
#[tokio::test]
async fn loads_both_in_order() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("AGENTS.md"), "Agents content.").unwrap();
std::fs::write(dir.path().join("CLAUDE.md"), "Claude content.").unwrap();
let mut p = AgentsProvider::new(true, None);
p.initialize(&ctx(dir.path().to_str().unwrap()))
.await
.unwrap();
let block = p.system_prompt_block().unwrap();
let agents_at = block.find("## AGENTS.md").unwrap();
let claude_at = block.find("## CLAUDE.md").unwrap();
assert!(agents_at < claude_at, "AGENTS.md should come first");
assert!(block.contains("Agents content."));
assert!(block.contains("Claude content."));
}
#[tokio::test]
async fn explicit_file_override_uses_real_filename() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("INSTRUCTIONS.md");
std::fs::write(&file, "Explicit file body.").unwrap();
let mut p = AgentsProvider::new(true, Some(file.to_string_lossy().into_owned()));
p.initialize(&ctx("/nonexistent")).await.unwrap();
let block = p.system_prompt_block().unwrap();
assert!(block.contains("## INSTRUCTIONS.md"));
assert!(block.contains("Explicit file body."));
}
#[tokio::test]
async fn explicit_dir_override_searches_inside() {
let ws = tempfile::tempdir().unwrap();
let sub = ws.path().join("subdir");
std::fs::create_dir_all(&sub).unwrap();
std::fs::write(sub.join("AGENTS.md"), "Subdir agents.").unwrap();
let mut p = AgentsProvider::new(true, Some(sub.to_string_lossy().into_owned()));
p.initialize(&ctx(ws.path().to_str().unwrap()))
.await
.unwrap();
let block = p.system_prompt_block().unwrap();
assert!(block.contains("## AGENTS.md"));
assert!(block.contains("Subdir agents."));
}
#[tokio::test]
async fn disabled_produces_no_block() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("AGENTS.md"), "ignored").unwrap();
let mut p = AgentsProvider::new(false, None);
p.initialize(&ctx(dir.path().to_str().unwrap()))
.await
.unwrap();
assert!(p.system_prompt_block().is_none());
}
#[tokio::test]
async fn missing_and_empty_produce_no_block() {
let dir = tempfile::tempdir().unwrap();
let mut p = AgentsProvider::new(true, None);
p.initialize(&ctx(dir.path().to_str().unwrap()))
.await
.unwrap();
assert!(p.system_prompt_block().is_none());
let dir2 = tempfile::tempdir().unwrap();
std::fs::write(dir2.path().join("AGENTS.md"), " \n\t ").unwrap();
let mut p2 = AgentsProvider::new(true, None);
p2.initialize(&ctx(dir2.path().to_str().unwrap()))
.await
.unwrap();
assert!(p2.system_prompt_block().is_none());
}
#[tokio::test]
async fn oversize_file_is_truncated() {
let dir = tempfile::tempdir().unwrap();
let big = "a".repeat(AgentsProvider::MAX_FILE_BYTES + 5_000);
std::fs::write(dir.path().join("AGENTS.md"), &big).unwrap();
let mut p = AgentsProvider::new(true, None);
p.initialize(&ctx(dir.path().to_str().unwrap()))
.await
.unwrap();
let block = p.system_prompt_block().unwrap();
assert!(block.contains("[... truncated]"));
let (_, contents) = &p.loaded[0];
assert!(contents.len() < big.len());
assert!(contents.ends_with("[... truncated]"));
}
#[tokio::test]
async fn relative_override_joined_to_workspace() {
let ws = tempfile::tempdir().unwrap();
let docs = ws.path().join("docs");
std::fs::create_dir_all(&docs).unwrap();
std::fs::write(docs.join("CLAUDE.md"), "Docs claude.").unwrap();
let mut p = AgentsProvider::new(true, Some("docs".to_string()));
p.initialize(&ctx(ws.path().to_str().unwrap()))
.await
.unwrap();
let block = p.system_prompt_block().unwrap();
assert!(block.contains("## CLAUDE.md"));
assert!(block.contains("Docs claude."));
}
#[tokio::test]
async fn absolute_override_honored() {
let other = tempfile::tempdir().unwrap();
std::fs::write(other.path().join("AGENTS.md"), "Absolute target.").unwrap();
let mut p = AgentsProvider::new(true, Some(other.path().to_string_lossy().into_owned()));
p.initialize(&ctx("/some/other/workspace")).await.unwrap();
let block = p.system_prompt_block().unwrap();
assert!(block.contains("Absolute target."));
}
#[tokio::test]
async fn build_messages_is_empty() {
let p = AgentsProvider::new(true, None);
assert!(p.build_messages("sys", "task").is_empty());
}
}