mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(sprout-agent): load AGENTS.md and SKILL.md into system prompt (#762)
This commit is contained in:
@@ -21,6 +21,7 @@ const ERROR_REFLECTION_SUFFIX: &str =
|
||||
pub struct RunCtx<'a> {
|
||||
pub cfg: &'a Config,
|
||||
pub session_id: &'a str,
|
||||
pub system_prompt: &'a str,
|
||||
pub llm: &'a Llm,
|
||||
pub mcp: &'a Arc<McpRegistry>,
|
||||
pub wire: &'a WireSender,
|
||||
@@ -73,7 +74,7 @@ impl RunCtx<'_> {
|
||||
let response = tokio::select! {
|
||||
biased;
|
||||
_ = self.cancel.changed() => return Ok(StopReason::Cancelled),
|
||||
r = self.llm.complete(self.cfg, self.history, &tools) => r?,
|
||||
r = self.llm.complete(self.cfg, self.system_prompt, self.history, &tools) => r?,
|
||||
};
|
||||
|
||||
if !response.text.is_empty() {
|
||||
|
||||
@@ -75,6 +75,7 @@ pub struct Config {
|
||||
pub anthropic_api_version: String,
|
||||
/// OpenAI endpoint selection. See [`OpenAiApi`].
|
||||
pub openai_api: OpenAiApi,
|
||||
pub hints_enabled: bool,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -156,6 +157,7 @@ impl Config {
|
||||
)?),
|
||||
stop_max_rejections: parse_env("SPROUT_AGENT_STOP_MAX_REJECTIONS", 3u32)?,
|
||||
hook_servers: parse_hook_servers_env("MCP_HOOK_SERVERS"),
|
||||
hints_enabled: parse_env("SPROUT_AGENT_NO_HINTS", 0u8)? == 0,
|
||||
};
|
||||
cfg.validate()?;
|
||||
Ok(cfg)
|
||||
|
||||
@@ -0,0 +1,525 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::mcp::truncate_at_boundary;
|
||||
|
||||
const MAX_HINTS_BYTES: usize = 128 * 1024;
|
||||
const MAX_SKILL_BODY_BYTES: usize = 32 * 1024;
|
||||
const SKILL_DIRS: &[&str] = &[".agents/skills", ".goose/skills", ".claude/skills"];
|
||||
|
||||
fn home_dir() -> Option<PathBuf> {
|
||||
std::env::var("HOME").ok().map(PathBuf::from)
|
||||
}
|
||||
|
||||
pub struct SkillEntry {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
/// Handles both normal repos (`.git/` dir) and worktrees (`.git` file).
|
||||
fn find_git_root(start: &Path) -> Option<PathBuf> {
|
||||
let mut current = start.to_path_buf();
|
||||
loop {
|
||||
if current.join(".git").exists() {
|
||||
return Some(current);
|
||||
}
|
||||
match current.parent() {
|
||||
Some(parent) => current = parent.to_path_buf(),
|
||||
None => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_hint_files_impl(cwd: &Path, home: Option<&Path>) -> String {
|
||||
let mut chain = match find_git_root(cwd) {
|
||||
Some(root) => {
|
||||
let mut c: Vec<PathBuf> = cwd
|
||||
.ancestors()
|
||||
.take_while(|a| a.starts_with(&root))
|
||||
.map(|a| a.to_path_buf())
|
||||
.collect();
|
||||
// ancestors() yields cwd first, root last — reverse for root→cwd.
|
||||
c.reverse();
|
||||
c
|
||||
}
|
||||
None => vec![cwd.to_path_buf()],
|
||||
};
|
||||
|
||||
// Prepend ~/AGENTS.md as global layer, unless ~ is already in the chain.
|
||||
if let Some(home) = home {
|
||||
if !chain.iter().any(|d| d == home) {
|
||||
chain.insert(0, home.to_path_buf());
|
||||
}
|
||||
}
|
||||
|
||||
let mut result = String::new();
|
||||
for dir in &chain {
|
||||
let path = dir.join("AGENTS.md");
|
||||
let Ok(content) = std::fs::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
if !result.is_empty() {
|
||||
result.push_str("\n\n");
|
||||
}
|
||||
let remaining = MAX_HINTS_BYTES.saturating_sub(result.len());
|
||||
if remaining == 0 {
|
||||
break;
|
||||
}
|
||||
if content.len() <= remaining {
|
||||
result.push_str(&content);
|
||||
} else {
|
||||
let truncated = truncate_at_boundary(&content, remaining);
|
||||
result.push_str(truncated);
|
||||
break;
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn parse_skill_frontmatter(content: &str) -> Option<(String, String, String)> {
|
||||
// Must start with `---`
|
||||
let rest = content.strip_prefix("---\n")?;
|
||||
// Find the closing `---`
|
||||
let close_pos = rest.find("\n---")?;
|
||||
let yaml_block = &rest[..close_pos];
|
||||
// Everything after the closing `---\n` (or `---` at end) is the body.
|
||||
let after_close = &rest[close_pos + 4..]; // skip "\n---"
|
||||
let body = after_close.strip_prefix('\n').unwrap_or(after_close);
|
||||
|
||||
let map: HashMap<String, serde_yaml::Value> = serde_yaml::from_str(yaml_block).ok()?;
|
||||
let name = map
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)?;
|
||||
let description = map
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let body = if body.len() > MAX_SKILL_BODY_BYTES {
|
||||
truncate_at_boundary(body, MAX_SKILL_BODY_BYTES).to_string()
|
||||
} else {
|
||||
body.to_string()
|
||||
};
|
||||
|
||||
Some((name, description, body))
|
||||
}
|
||||
|
||||
fn scan_skill_dir(dir: &Path, seen: &mut HashSet<String>, skills: &mut Vec<SkillEntry>) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
let mut subdirs: Vec<PathBuf> = entries
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
|
||||
.map(|e| e.path())
|
||||
.collect();
|
||||
subdirs.sort();
|
||||
|
||||
for subdir in subdirs {
|
||||
let skill_md = subdir.join("SKILL.md");
|
||||
let Ok(content) = std::fs::read_to_string(&skill_md) else {
|
||||
continue;
|
||||
};
|
||||
let Some((name, description, body)) = parse_skill_frontmatter(&content) else {
|
||||
continue;
|
||||
};
|
||||
if seen.contains(&name) {
|
||||
continue;
|
||||
}
|
||||
seen.insert(name.clone());
|
||||
skills.push(SkillEntry {
|
||||
name,
|
||||
description,
|
||||
body,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn discover_skills_impl(cwd: &Path, home: Option<&Path>) -> Vec<SkillEntry> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut skills = Vec::new();
|
||||
|
||||
for dir_suffix in SKILL_DIRS {
|
||||
scan_skill_dir(&cwd.join(dir_suffix), &mut seen, &mut skills);
|
||||
}
|
||||
|
||||
if let Some(home) = home {
|
||||
scan_skill_dir(&home.join(".agents/skills"), &mut seen, &mut skills);
|
||||
}
|
||||
|
||||
skills
|
||||
}
|
||||
|
||||
pub fn build_hints_section(cwd: &Path) -> String {
|
||||
build_hints_section_impl(cwd, home_dir().as_deref())
|
||||
}
|
||||
|
||||
fn build_hints_section_impl(cwd: &Path, home: Option<&Path>) -> String {
|
||||
let hints_text = load_hint_files_impl(cwd, home);
|
||||
let skills = discover_skills_impl(cwd, home);
|
||||
|
||||
if hints_text.is_empty() && skills.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let mut out = String::from("# Additional Instructions\n");
|
||||
|
||||
if !hints_text.is_empty() {
|
||||
out.push_str("\n## Project Hints\n");
|
||||
out.push_str(&hints_text);
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
if !skills.is_empty() {
|
||||
out.push_str("\n## Available Skills\n");
|
||||
for skill in &skills {
|
||||
out.push_str(&format!("- {}: {}\n", skill.name, skill.description));
|
||||
}
|
||||
for skill in &skills {
|
||||
out.push_str(&format!("\n### {}\n", skill.name));
|
||||
out.push_str(&skill.body);
|
||||
if !skill.body.ends_with('\n') {
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn find_git_root_normal_repo() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::create_dir(root.join(".git")).unwrap();
|
||||
assert_eq!(find_git_root(root), Some(root.to_path_buf()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_git_root_worktree() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
// .git as a file (worktree)
|
||||
std::fs::write(root.join(".git"), "gitdir: ../main/.git/worktrees/wt").unwrap();
|
||||
assert_eq!(find_git_root(root), Some(root.to_path_buf()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_git_root_none() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
// No .git anywhere under tmp
|
||||
let result = find_git_root(tmp.path());
|
||||
// In a CI environment the test itself may live inside a real git repo,
|
||||
// so only assert None when tmp is truly isolated (not a subpath of a git repo).
|
||||
// We verify by checking that any found root is NOT inside tmp.
|
||||
if let Some(found) = result {
|
||||
assert!(!found.starts_with(tmp.path()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_git_root_from_subdirectory() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::create_dir(root.join(".git")).unwrap();
|
||||
let deep = root.join("sub").join("deep");
|
||||
std::fs::create_dir_all(&deep).unwrap();
|
||||
assert_eq!(find_git_root(&deep), Some(root.to_path_buf()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_hint_files_single_at_cwd() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let cwd = tmp.path();
|
||||
// No .git → no git root discovery; only cwd is checked.
|
||||
std::fs::write(cwd.join("AGENTS.md"), "cwd hints").unwrap();
|
||||
let result = load_hint_files_impl(cwd, None);
|
||||
assert_eq!(result, "cwd hints");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_hint_files_git_root_and_cwd() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::create_dir(root.join(".git")).unwrap();
|
||||
std::fs::write(root.join("AGENTS.md"), "root hints").unwrap();
|
||||
let sub = root.join("sub");
|
||||
std::fs::create_dir(&sub).unwrap();
|
||||
std::fs::write(sub.join("AGENTS.md"), "sub hints").unwrap();
|
||||
let result = load_hint_files_impl(&sub, None);
|
||||
// Root hints must come first.
|
||||
assert!(
|
||||
result.starts_with("root hints"),
|
||||
"expected root hints first, got: {result:?}"
|
||||
);
|
||||
assert!(result.contains("sub hints"), "missing sub hints");
|
||||
let root_pos = result.find("root hints").unwrap();
|
||||
let sub_pos = result.find("sub hints").unwrap();
|
||||
assert!(root_pos < sub_pos, "root hints should precede sub hints");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_hint_files_missing_files() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let result = load_hint_files_impl(tmp.path(), None);
|
||||
assert_eq!(result, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_skills_finds_across_dirs() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let cwd = tmp.path();
|
||||
|
||||
// Skill in .agents/skills/
|
||||
let agents_skill = cwd.join(".agents/skills/my-skill");
|
||||
std::fs::create_dir_all(&agents_skill).unwrap();
|
||||
std::fs::write(
|
||||
agents_skill.join("SKILL.md"),
|
||||
"---\nname: my-skill\ndescription: A skill\n---\nSkill body here.\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Skill in .goose/skills/
|
||||
let goose_skill = cwd.join(".goose/skills/other-skill");
|
||||
std::fs::create_dir_all(&goose_skill).unwrap();
|
||||
std::fs::write(
|
||||
goose_skill.join("SKILL.md"),
|
||||
"---\nname: other-skill\ndescription: Another skill\n---\nOther body.\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let skills = discover_skills_impl(cwd, None);
|
||||
assert_eq!(skills.len(), 2);
|
||||
let names: Vec<&str> = skills.iter().map(|s| s.name.as_str()).collect();
|
||||
assert!(names.contains(&"my-skill"), "missing my-skill");
|
||||
assert!(names.contains(&"other-skill"), "missing other-skill");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_skills_dedup_by_name() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let cwd = tmp.path();
|
||||
|
||||
// Same name in .agents/skills/ (first) and .goose/skills/ (second)
|
||||
let agents_skill = cwd.join(".agents/skills/shared");
|
||||
std::fs::create_dir_all(&agents_skill).unwrap();
|
||||
std::fs::write(
|
||||
agents_skill.join("SKILL.md"),
|
||||
"---\nname: shared\ndescription: from agents\n---\nAgents body.\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let goose_skill = cwd.join(".goose/skills/shared");
|
||||
std::fs::create_dir_all(&goose_skill).unwrap();
|
||||
std::fs::write(
|
||||
goose_skill.join("SKILL.md"),
|
||||
"---\nname: shared\ndescription: from goose\n---\nGoose body.\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let skills = discover_skills_impl(cwd, None);
|
||||
assert_eq!(skills.len(), 1, "duplicate name should be deduplicated");
|
||||
assert_eq!(
|
||||
skills[0].description, "from agents",
|
||||
"first wins (.agents/)"
|
||||
);
|
||||
assert_eq!(skills[0].body.trim(), "Agents body.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_skills_skips_missing_name() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let cwd = tmp.path();
|
||||
|
||||
let skill_dir = cwd.join(".agents/skills/no-name");
|
||||
std::fs::create_dir_all(&skill_dir).unwrap();
|
||||
std::fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
"---\ndescription: No name here\n---\nBody.\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let skills = discover_skills_impl(cwd, None);
|
||||
assert!(skills.is_empty(), "entry without name should be skipped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_hints_section_empty() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let result = build_hints_section_impl(tmp.path(), None);
|
||||
assert_eq!(result, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_hints_section_combined() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let cwd = tmp.path();
|
||||
|
||||
std::fs::write(cwd.join("AGENTS.md"), "Project-level hints.").unwrap();
|
||||
|
||||
let skill_dir = cwd.join(".agents/skills/sprout-cli");
|
||||
std::fs::create_dir_all(&skill_dir).unwrap();
|
||||
std::fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
"---\nname: sprout-cli\ndescription: CLI reference for Sprout managed agents\n---\nUse `sprout` to manage agents.\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let result = build_hints_section_impl(cwd, None);
|
||||
|
||||
assert!(
|
||||
result.contains("# Additional Instructions"),
|
||||
"missing header"
|
||||
);
|
||||
assert!(result.contains("## Project Hints"), "missing Project Hints");
|
||||
assert!(
|
||||
result.contains("Project-level hints."),
|
||||
"missing hints content"
|
||||
);
|
||||
assert!(
|
||||
result.contains("## Available Skills"),
|
||||
"missing Available Skills"
|
||||
);
|
||||
assert!(
|
||||
result.contains("sprout-cli: CLI reference for Sprout managed agents"),
|
||||
"missing skill bullet"
|
||||
);
|
||||
assert!(result.contains("### sprout-cli"), "missing skill header");
|
||||
assert!(
|
||||
result.contains("Use `sprout` to manage agents."),
|
||||
"missing skill body"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_hint_files_global_loaded_first() {
|
||||
let home = TempDir::new().unwrap();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
std::fs::write(home.path().join("AGENTS.md"), "global hints").unwrap();
|
||||
std::fs::write(cwd.path().join("AGENTS.md"), "local hints").unwrap();
|
||||
let result = load_hint_files_impl(cwd.path(), Some(home.path()));
|
||||
let global_pos = result.find("global hints").unwrap();
|
||||
let local_pos = result.find("local hints").unwrap();
|
||||
assert!(
|
||||
global_pos < local_pos,
|
||||
"global hints should precede local hints"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_hint_files_home_missing_agents_md() {
|
||||
let home = TempDir::new().unwrap();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
std::fs::write(cwd.path().join("AGENTS.md"), "local only").unwrap();
|
||||
let result = load_hint_files_impl(cwd.path(), Some(home.path()));
|
||||
assert_eq!(result, "local only");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_hint_files_no_home_dir() {
|
||||
let cwd = TempDir::new().unwrap();
|
||||
std::fs::write(cwd.path().join("AGENTS.md"), "local only").unwrap();
|
||||
let result = load_hint_files_impl(cwd.path(), None);
|
||||
assert_eq!(result, "local only");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_hint_files_dedup_when_home_in_chain() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let home = tmp.path();
|
||||
std::fs::write(home.join("AGENTS.md"), "single load").unwrap();
|
||||
let result = load_hint_files_impl(home, Some(home));
|
||||
assert_eq!(
|
||||
result.matches("single load").count(),
|
||||
1,
|
||||
"AGENTS.md should be loaded exactly once when CWD is home"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_hint_files_dedup_when_home_is_git_root() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let home = tmp.path();
|
||||
std::fs::create_dir(home.join(".git")).unwrap();
|
||||
std::fs::write(home.join("AGENTS.md"), "root+home hints").unwrap();
|
||||
let sub = home.join("sub");
|
||||
std::fs::create_dir(&sub).unwrap();
|
||||
let result = load_hint_files_impl(&sub, Some(home));
|
||||
assert_eq!(
|
||||
result.matches("root+home hints").count(),
|
||||
1,
|
||||
"AGENTS.md should be loaded once when home is git root"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_skills_global_skills_loaded() {
|
||||
let home = TempDir::new().unwrap();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let skill_dir = home.path().join(".agents/skills/global-skill");
|
||||
std::fs::create_dir_all(&skill_dir).unwrap();
|
||||
std::fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
"---\nname: global-skill\ndescription: A global skill\n---\nGlobal body.\n",
|
||||
)
|
||||
.unwrap();
|
||||
let skills = discover_skills_impl(cwd.path(), Some(home.path()));
|
||||
assert_eq!(skills.len(), 1);
|
||||
assert_eq!(skills[0].name, "global-skill");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_skills_project_wins_over_global() {
|
||||
let home = TempDir::new().unwrap();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
|
||||
let project_skill = cwd.path().join(".agents/skills/shared");
|
||||
std::fs::create_dir_all(&project_skill).unwrap();
|
||||
std::fs::write(
|
||||
project_skill.join("SKILL.md"),
|
||||
"---\nname: shared\ndescription: from project\n---\nProject body.\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let global_skill = home.path().join(".agents/skills/shared");
|
||||
std::fs::create_dir_all(&global_skill).unwrap();
|
||||
std::fs::write(
|
||||
global_skill.join("SKILL.md"),
|
||||
"---\nname: shared\ndescription: from global\n---\nGlobal body.\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let skills = discover_skills_impl(cwd.path(), Some(home.path()));
|
||||
assert_eq!(skills.len(), 1, "duplicate name should be deduplicated");
|
||||
assert_eq!(
|
||||
skills[0].description, "from project",
|
||||
"project-level should win over global"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_skills_no_home_dir() {
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let skill_dir = cwd.path().join(".agents/skills/local");
|
||||
std::fs::create_dir_all(&skill_dir).unwrap();
|
||||
std::fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
"---\nname: local\ndescription: Local skill\n---\nBody.\n",
|
||||
)
|
||||
.unwrap();
|
||||
let skills = discover_skills_impl(cwd.path(), None);
|
||||
assert_eq!(skills.len(), 1);
|
||||
assert_eq!(skills[0].name, "local");
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ mod agent;
|
||||
pub mod auth;
|
||||
mod config;
|
||||
mod handoff;
|
||||
mod hints;
|
||||
mod llm;
|
||||
mod mcp;
|
||||
mod types;
|
||||
@@ -41,6 +42,7 @@ struct Session {
|
||||
original_task: Option<String>,
|
||||
handoff_count: usize,
|
||||
stop_rejections: u32,
|
||||
effective_system_prompt: Arc<str>,
|
||||
}
|
||||
|
||||
fn die(msg: String) -> ! {
|
||||
@@ -253,6 +255,16 @@ async fn session_new(app: &Arc<App>, id: Value, params: Value, wire_tx: &WireSen
|
||||
.await;
|
||||
}
|
||||
}
|
||||
let effective_system_prompt: Arc<str> = if app.cfg.hints_enabled {
|
||||
let hints = hints::build_hints_section(std::path::Path::new(&p.cwd));
|
||||
if hints.is_empty() {
|
||||
Arc::from(app.cfg.system_prompt.as_str())
|
||||
} else {
|
||||
Arc::from(format!("{}\n\n{}", app.cfg.system_prompt, hints))
|
||||
}
|
||||
} else {
|
||||
Arc::from(app.cfg.system_prompt.as_str())
|
||||
};
|
||||
let mcp = match McpRegistry::spawn_all(&app.cfg, &p.mcp_servers, &p.cwd).await {
|
||||
Ok(m) => Arc::new(m),
|
||||
Err(e) => return reject(wire_tx, id, e.json_rpc_code(), &e.to_string()).await,
|
||||
@@ -284,6 +296,7 @@ async fn session_new(app: &Arc<App>, id: Value, params: Value, wire_tx: &WireSen
|
||||
original_task: None,
|
||||
handoff_count: 0,
|
||||
stop_rejections: 0,
|
||||
effective_system_prompt,
|
||||
},
|
||||
);
|
||||
drop(sessions);
|
||||
@@ -323,6 +336,7 @@ async fn run_prompt(app: Arc<App>, id: Value, params: Value, wire_tx: WireSender
|
||||
mut handoff_count,
|
||||
mut stop_rejections,
|
||||
mut cancel_rx,
|
||||
effective_system_prompt,
|
||||
) = match acquire_session(&app, &p.session_id).await {
|
||||
Ok(v) => v,
|
||||
Err(reason) => {
|
||||
@@ -338,6 +352,7 @@ async fn run_prompt(app: Arc<App>, id: Value, params: Value, wire_tx: WireSender
|
||||
let mut ctx = RunCtx {
|
||||
cfg: &app.cfg,
|
||||
session_id: &sid,
|
||||
system_prompt: &effective_system_prompt,
|
||||
llm: &app.llm,
|
||||
mcp: &mcp,
|
||||
wire: &wire_tx,
|
||||
@@ -379,6 +394,7 @@ async fn acquire_session(
|
||||
usize,
|
||||
u32,
|
||||
watch::Receiver<bool>,
|
||||
Arc<str>,
|
||||
),
|
||||
&'static str,
|
||||
> {
|
||||
@@ -398,6 +414,7 @@ async fn acquire_session(
|
||||
s.handoff_count,
|
||||
s.stop_rejections,
|
||||
rx,
|
||||
Arc::clone(&s.effective_system_prompt),
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -56,13 +56,14 @@ impl Llm {
|
||||
pub async fn complete(
|
||||
&self,
|
||||
cfg: &Config,
|
||||
system_prompt: &str,
|
||||
history: &[HistoryItem],
|
||||
tools: &[ToolDef],
|
||||
) -> Result<LlmResponse, AgentError> {
|
||||
match cfg.provider {
|
||||
Provider::Anthropic => {
|
||||
let v = self
|
||||
.post_anthropic(cfg, &anthropic_body(cfg, history, tools))
|
||||
.post_anthropic(cfg, &anthropic_body(cfg, system_prompt, history, tools))
|
||||
.await?;
|
||||
parse_anthropic(v)
|
||||
}
|
||||
@@ -70,12 +71,12 @@ impl Llm {
|
||||
self.openai_request(cfg, |use_responses| {
|
||||
if use_responses {
|
||||
(
|
||||
responses_body(cfg, history, tools),
|
||||
responses_body(cfg, system_prompt, history, tools),
|
||||
parse_responses as OpenAiParse,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
openai_body(cfg, history, tools),
|
||||
openai_body(cfg, system_prompt, history, tools),
|
||||
parse_openai as OpenAiParse,
|
||||
)
|
||||
}
|
||||
@@ -227,7 +228,12 @@ impl Llm {
|
||||
}
|
||||
}
|
||||
|
||||
fn anthropic_body(cfg: &Config, history: &[HistoryItem], tools: &[ToolDef]) -> Value {
|
||||
fn anthropic_body(
|
||||
cfg: &Config,
|
||||
system_prompt: &str,
|
||||
history: &[HistoryItem],
|
||||
tools: &[ToolDef],
|
||||
) -> Value {
|
||||
let mut messages: Vec<Value> = Vec::new();
|
||||
let mut pending: Vec<Value> = Vec::new();
|
||||
let flush = |out: &mut Vec<Value>, p: &mut Vec<Value>| {
|
||||
@@ -275,7 +281,7 @@ fn anthropic_body(cfg: &Config, history: &[HistoryItem], tools: &[ToolDef]) -> V
|
||||
})
|
||||
.collect();
|
||||
let mut body = json!({ "model": cfg.model, "max_tokens": cfg.max_output_tokens,
|
||||
"system": cfg.system_prompt, "messages": messages });
|
||||
"system": system_prompt, "messages": messages });
|
||||
if !tools_json.is_empty() {
|
||||
body["tools"] = Value::Array(tools_json);
|
||||
}
|
||||
@@ -295,8 +301,13 @@ fn anthropic_tool_result_content(content: &[ToolResultContent]) -> Vec<Value> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn openai_body(cfg: &Config, history: &[HistoryItem], tools: &[ToolDef]) -> Value {
|
||||
let mut messages: Vec<Value> = vec![json!({ "role": "system", "content": cfg.system_prompt })];
|
||||
fn openai_body(
|
||||
cfg: &Config,
|
||||
system_prompt: &str,
|
||||
history: &[HistoryItem],
|
||||
tools: &[ToolDef],
|
||||
) -> Value {
|
||||
let mut messages: Vec<Value> = vec![json!({ "role": "system", "content": system_prompt })];
|
||||
// Images returned from tool calls ride on a trailing `role:"user"`
|
||||
// message because OpenAI Chat's `role:"tool"` content is text-only. We
|
||||
// batch them across a run of adjacent ToolResult items so that all
|
||||
@@ -399,7 +410,12 @@ fn openai_image_user_content(content: &[ToolResultContent]) -> Vec<Value> {
|
||||
// "No tool call found for call_id ...". `HistoryItem` ordering already
|
||||
// guarantees this.
|
||||
|
||||
fn responses_body(cfg: &Config, history: &[HistoryItem], tools: &[ToolDef]) -> Value {
|
||||
fn responses_body(
|
||||
cfg: &Config,
|
||||
system_prompt: &str,
|
||||
history: &[HistoryItem],
|
||||
tools: &[ToolDef],
|
||||
) -> Value {
|
||||
let mut input: Vec<Value> = Vec::with_capacity(history.len());
|
||||
for item in history {
|
||||
match item {
|
||||
@@ -463,7 +479,7 @@ fn responses_body(cfg: &Config, history: &[HistoryItem], tools: &[ToolDef]) -> V
|
||||
|
||||
let mut body = json!({
|
||||
"model": cfg.model,
|
||||
"instructions": cfg.system_prompt,
|
||||
"instructions": system_prompt,
|
||||
"max_output_tokens": cfg.max_output_tokens,
|
||||
"input": input,
|
||||
});
|
||||
@@ -864,6 +880,7 @@ mod tests {
|
||||
base_url: "http://example.invalid".into(),
|
||||
anthropic_api_version: "2023-06-01".into(),
|
||||
openai_api: OpenAiApi::Chat,
|
||||
hints_enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -894,7 +911,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn anthropic_tool_result_preserves_image_block() {
|
||||
let body = anthropic_body(&cfg(Provider::Anthropic), &image_history(), &[]);
|
||||
let body = anthropic_body(&cfg(Provider::Anthropic), "system", &image_history(), &[]);
|
||||
let content = &body["messages"][2]["content"][0]["content"];
|
||||
assert_eq!(content[0]["type"], "text");
|
||||
assert_eq!(content[1]["type"], "image");
|
||||
@@ -940,7 +957,12 @@ mod tests {
|
||||
"properties": {"command": {"type": "string"}},
|
||||
}),
|
||||
}];
|
||||
let body = responses_body(&cfg_responses(), &[HistoryItem::User("hi".into())], &tools);
|
||||
let body = responses_body(
|
||||
&cfg_responses(),
|
||||
"system",
|
||||
&[HistoryItem::User("hi".into())],
|
||||
&tools,
|
||||
);
|
||||
assert_eq!(body["model"], "model");
|
||||
assert_eq!(body["instructions"], "system");
|
||||
assert_eq!(body["max_output_tokens"], 1024);
|
||||
@@ -968,7 +990,7 @@ mod tests {
|
||||
// function_call item *must* appear in `input[]` before its matching
|
||||
// function_call_output, otherwise the API rejects with
|
||||
// "No tool call found for call_id ...".
|
||||
let body = responses_body(&cfg_responses(), &tool_call_history(), &[]);
|
||||
let body = responses_body(&cfg_responses(), "system", &tool_call_history(), &[]);
|
||||
let input = body["input"].as_array().unwrap();
|
||||
|
||||
// [0] user, [1] assistant text, [2] function_call, [3] function_call_output
|
||||
@@ -1007,7 +1029,7 @@ mod tests {
|
||||
}],
|
||||
},
|
||||
];
|
||||
let body = responses_body(&cfg_responses(), &history, &[]);
|
||||
let body = responses_body(&cfg_responses(), "system", &history, &[]);
|
||||
let input = body["input"].as_array().unwrap();
|
||||
assert_eq!(input.len(), 2);
|
||||
assert_eq!(input[0]["role"], "user");
|
||||
@@ -1016,7 +1038,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn responses_body_image_tool_result_attaches_input_image() {
|
||||
let body = responses_body(&cfg_responses(), &image_history(), &[]);
|
||||
let body = responses_body(&cfg_responses(), "system", &image_history(), &[]);
|
||||
let input = body["input"].as_array().unwrap();
|
||||
// function_call_output carries the text part; image rides on a
|
||||
// trailing user message as `input_image`.
|
||||
@@ -1118,7 +1140,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn openai_tool_result_adds_followup_image_user_message() {
|
||||
let body = openai_body(&cfg(Provider::OpenAi), &image_history(), &[]);
|
||||
let body = openai_body(&cfg(Provider::OpenAi), "system", &image_history(), &[]);
|
||||
assert_eq!(body["messages"][3]["role"], "tool");
|
||||
assert!(body["messages"][3]["content"]
|
||||
.as_str()
|
||||
@@ -1189,7 +1211,7 @@ mod tests {
|
||||
is_error: false,
|
||||
}),
|
||||
];
|
||||
let body = openai_body(&cfg(Provider::OpenAi), &history, &[]);
|
||||
let body = openai_body(&cfg(Provider::OpenAi), "system", &history, &[]);
|
||||
let messages = body["messages"].as_array().unwrap();
|
||||
// [0] system, [1] user, [2] assistant(tool_calls), [3] tool A, [4] tool B, [5] user(images)
|
||||
assert_eq!(messages.len(), 6, "messages: {messages:#?}");
|
||||
|
||||
@@ -822,7 +822,7 @@ fn valid_name(s: &str) -> bool {
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
|
||||
}
|
||||
|
||||
fn truncate_at_boundary(s: &str, max: usize) -> &str {
|
||||
pub(crate) fn truncate_at_boundary(s: &str, max: usize) -> &str {
|
||||
if s.len() <= max {
|
||||
return s;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
//! Integration tests for AGENTS.md / SKILL.md hint loading.
|
||||
//!
|
||||
//! Uses the same subprocess + capturing-LLM pattern as `regressions.rs`.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
// ─── Fake LLM ────────────────────────────────────────────────────────────────
|
||||
|
||||
struct CapturingLlm {
|
||||
url: String,
|
||||
captured: Arc<Mutex<Vec<Value>>>,
|
||||
}
|
||||
|
||||
async fn spawn_capturing_llm(responses: Vec<Value>) -> CapturingLlm {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let queue = Arc::new(Mutex::new(VecDeque::from(responses)));
|
||||
let captured: Arc<Mutex<Vec<Value>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let cap2 = captured.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let (mut sock, _) = match listener.accept().await {
|
||||
Ok(p) => p,
|
||||
Err(_) => return,
|
||||
};
|
||||
let queue = queue.clone();
|
||||
let captured = cap2.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut buf = Vec::new();
|
||||
let mut tmp = [0u8; 8192];
|
||||
while !buf.windows(4).any(|w| w == b"\r\n\r\n") {
|
||||
match sock.read(&mut tmp).await {
|
||||
Ok(0) | Err(_) => return,
|
||||
Ok(n) => buf.extend_from_slice(&tmp[..n]),
|
||||
}
|
||||
if buf.len() > 4_000_000 {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let header_end = buf.windows(4).position(|w| w == b"\r\n\r\n").unwrap() + 4;
|
||||
let headers = &buf[..header_end];
|
||||
let mut body_len = 0usize;
|
||||
for line in headers.split(|b| *b == b'\n') {
|
||||
let line = std::str::from_utf8(line).unwrap_or("");
|
||||
if let Some(rest) = line.to_ascii_lowercase().strip_prefix("content-length:") {
|
||||
body_len = rest.trim().trim_end_matches('\r').parse().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
while buf.len() < header_end + body_len {
|
||||
match sock.read(&mut tmp).await {
|
||||
Ok(0) | Err(_) => return,
|
||||
Ok(n) => buf.extend_from_slice(&tmp[..n]),
|
||||
}
|
||||
}
|
||||
if let Ok(req) = serde_json::from_slice::<Value>(&buf[header_end..]) {
|
||||
captured.lock().await.push(req);
|
||||
}
|
||||
let body = queue
|
||||
.lock()
|
||||
.await
|
||||
.pop_front()
|
||||
.unwrap_or_else(|| json!({ "error": "no canned response" }));
|
||||
let body_s = serde_json::to_string(&body).unwrap();
|
||||
let resp = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\
|
||||
Content-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
body_s.len(),
|
||||
body_s,
|
||||
);
|
||||
let _ = sock.write_all(resp.as_bytes()).await;
|
||||
let _ = sock.shutdown().await;
|
||||
});
|
||||
}
|
||||
});
|
||||
CapturingLlm { url, captured }
|
||||
}
|
||||
|
||||
// ─── Harness ─────────────────────────────────────────────────────────────────
|
||||
|
||||
struct Harness {
|
||||
child: tokio::process::Child,
|
||||
stdin: tokio::process::ChildStdin,
|
||||
stdout: BufReader<tokio::process::ChildStdout>,
|
||||
next_id: i64,
|
||||
}
|
||||
|
||||
impl Harness {
|
||||
async fn spawn_with_env(base_url: &str, extra: &[(&str, &str)]) -> Self {
|
||||
let bin = env!("CARGO_BIN_EXE_sprout-agent");
|
||||
let mut cmd = tokio::process::Command::new(bin);
|
||||
cmd.env("SPROUT_AGENT_PROVIDER", "openai")
|
||||
.env("OPENAI_COMPAT_API_KEY", "test")
|
||||
.env("OPENAI_COMPAT_MODEL", "fake-model")
|
||||
.env("OPENAI_COMPAT_BASE_URL", base_url)
|
||||
.env("SPROUT_AGENT_LLM_TIMEOUT_SECS", "5")
|
||||
.env("SPROUT_AGENT_TOOL_TIMEOUT_SECS", "5")
|
||||
.env("SPROUT_AGENT_MAX_ROUNDS", "8")
|
||||
.env("SPROUT_AGENT_MCP_INIT_TIMEOUT_SECS", "2");
|
||||
for (k, v) in extra {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
cmd.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit())
|
||||
.kill_on_drop(true);
|
||||
let mut child = cmd.spawn().expect("spawn sprout-agent");
|
||||
let stdin = child.stdin.take().unwrap();
|
||||
let stdout = BufReader::new(child.stdout.take().unwrap());
|
||||
Self {
|
||||
child,
|
||||
stdin,
|
||||
stdout,
|
||||
next_id: 1,
|
||||
}
|
||||
}
|
||||
|
||||
async fn send(&mut self, method: &str, params: Value) -> i64 {
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
self.write(json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }))
|
||||
.await;
|
||||
id
|
||||
}
|
||||
|
||||
async fn write(&mut self, msg: Value) {
|
||||
let mut s = serde_json::to_string(&msg).unwrap();
|
||||
s.push('\n');
|
||||
self.stdin.write_all(s.as_bytes()).await.unwrap();
|
||||
self.stdin.flush().await.unwrap();
|
||||
}
|
||||
|
||||
async fn recv(&mut self) -> Value {
|
||||
let mut line = String::new();
|
||||
let n = tokio::time::timeout(Duration::from_secs(15), self.stdout.read_line(&mut line))
|
||||
.await
|
||||
.expect("recv timeout")
|
||||
.expect("read line");
|
||||
assert!(n > 0, "agent EOF");
|
||||
serde_json::from_str(&line).expect("non-JSON line")
|
||||
}
|
||||
|
||||
async fn recv_until<F: FnMut(&Value) -> bool>(&mut self, mut pred: F) -> Value {
|
||||
loop {
|
||||
let v = self.recv().await;
|
||||
if pred(&v) {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn shutdown(mut self) {
|
||||
drop(self.stdin);
|
||||
let _ = tokio::time::timeout(Duration::from_secs(2), self.child.wait()).await;
|
||||
let _ = self.child.start_kill();
|
||||
}
|
||||
}
|
||||
|
||||
fn openai_text(content: &str) -> Value {
|
||||
json!({
|
||||
"id": "cc-1", "object": "chat.completion", "model": "fake-model",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": { "role": "assistant", "content": content },
|
||||
"finish_reason": "stop",
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
async fn init_session(h: &mut Harness, cwd: &str) -> String {
|
||||
h.send(
|
||||
"initialize",
|
||||
json!({"protocolVersion": 1, "clientCapabilities": {}}),
|
||||
)
|
||||
.await;
|
||||
let _ = h.recv().await;
|
||||
h.send("session/new", json!({"cwd": cwd, "mcpServers": []}))
|
||||
.await;
|
||||
let r = h
|
||||
.recv_until(|v| v.get("result").is_some() || v.get("error").is_some())
|
||||
.await;
|
||||
r["result"]["sessionId"]
|
||||
.as_str()
|
||||
.expect("sessionId")
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// AGENTS.md in cwd is loaded into the system prompt.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn hints_loaded_from_cwd_agents_md() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let cwd = tmp.path();
|
||||
let marker = "SPROUT_HINTS_MARKER_42";
|
||||
std::fs::write(cwd.join("AGENTS.md"), marker).unwrap();
|
||||
|
||||
let llm = spawn_capturing_llm(vec![openai_text("done")]).await;
|
||||
let mut h = Harness::spawn_with_env(&llm.url, &[]).await;
|
||||
let sid = init_session(&mut h, cwd.to_str().unwrap()).await;
|
||||
|
||||
let p = h
|
||||
.send(
|
||||
"session/prompt",
|
||||
json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}),
|
||||
)
|
||||
.await;
|
||||
let _ = h.recv_until(|v| v["id"] == json!(p)).await;
|
||||
|
||||
let captured = llm.captured.lock().await;
|
||||
assert!(!captured.is_empty(), "no LLM request captured");
|
||||
let system = captured[0]["messages"][0]["content"].as_str().unwrap_or("");
|
||||
assert!(
|
||||
system.contains(marker),
|
||||
"system prompt does not contain AGENTS.md marker: {system}"
|
||||
);
|
||||
h.shutdown().await;
|
||||
}
|
||||
|
||||
/// SPROUT_AGENT_NO_HINTS=1 suppresses hint loading.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn hints_suppressed_with_env_var() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let cwd = tmp.path();
|
||||
let marker = "SUPPRESS_CHECK_MARKER_99";
|
||||
std::fs::write(cwd.join("AGENTS.md"), marker).unwrap();
|
||||
|
||||
let llm = spawn_capturing_llm(vec![openai_text("done")]).await;
|
||||
let mut h = Harness::spawn_with_env(&llm.url, &[("SPROUT_AGENT_NO_HINTS", "1")]).await;
|
||||
let sid = init_session(&mut h, cwd.to_str().unwrap()).await;
|
||||
|
||||
let p = h
|
||||
.send(
|
||||
"session/prompt",
|
||||
json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}),
|
||||
)
|
||||
.await;
|
||||
let _ = h.recv_until(|v| v["id"] == json!(p)).await;
|
||||
|
||||
let captured = llm.captured.lock().await;
|
||||
assert!(!captured.is_empty(), "no LLM request captured");
|
||||
let system = captured[0]["messages"][0]["content"].as_str().unwrap_or("");
|
||||
assert!(
|
||||
!system.contains(marker),
|
||||
"system prompt should NOT contain marker when hints disabled: {system}"
|
||||
);
|
||||
h.shutdown().await;
|
||||
}
|
||||
|
||||
/// SKILL.md files in .agents/skills/ are loaded into the system prompt.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn skills_loaded_from_agents_skills_dir() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let cwd = tmp.path();
|
||||
let skill_dir = cwd.join(".agents/skills/test-skill");
|
||||
std::fs::create_dir_all(&skill_dir).unwrap();
|
||||
std::fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
"---\nname: test-skill\ndescription: A test skill\n---\nSKILL_BODY_MARKER_77\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let llm = spawn_capturing_llm(vec![openai_text("done")]).await;
|
||||
let mut h = Harness::spawn_with_env(&llm.url, &[]).await;
|
||||
let sid = init_session(&mut h, cwd.to_str().unwrap()).await;
|
||||
|
||||
let p = h
|
||||
.send(
|
||||
"session/prompt",
|
||||
json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}),
|
||||
)
|
||||
.await;
|
||||
let _ = h.recv_until(|v| v["id"] == json!(p)).await;
|
||||
|
||||
let captured = llm.captured.lock().await;
|
||||
assert!(!captured.is_empty(), "no LLM request captured");
|
||||
let system = captured[0]["messages"][0]["content"].as_str().unwrap_or("");
|
||||
assert!(
|
||||
system.contains("test-skill"),
|
||||
"system prompt missing skill name: {system}"
|
||||
);
|
||||
assert!(
|
||||
system.contains("SKILL_BODY_MARKER_77"),
|
||||
"system prompt missing skill body: {system}"
|
||||
);
|
||||
h.shutdown().await;
|
||||
}
|
||||
|
||||
/// AGENTS.md files at git root and subdirectory are both loaded, root first.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn git_root_hints_included() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::create_dir(root.join(".git")).unwrap();
|
||||
std::fs::write(root.join("AGENTS.md"), "ROOT_HINT_MARKER_11").unwrap();
|
||||
let sub = root.join("sub");
|
||||
std::fs::create_dir(&sub).unwrap();
|
||||
std::fs::write(sub.join("AGENTS.md"), "SUB_HINT_MARKER_22").unwrap();
|
||||
|
||||
let llm = spawn_capturing_llm(vec![openai_text("done")]).await;
|
||||
let mut h = Harness::spawn_with_env(&llm.url, &[]).await;
|
||||
let sid = init_session(&mut h, sub.to_str().unwrap()).await;
|
||||
|
||||
let p = h
|
||||
.send(
|
||||
"session/prompt",
|
||||
json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}),
|
||||
)
|
||||
.await;
|
||||
let _ = h.recv_until(|v| v["id"] == json!(p)).await;
|
||||
|
||||
let captured = llm.captured.lock().await;
|
||||
assert!(!captured.is_empty(), "no LLM request captured");
|
||||
let system = captured[0]["messages"][0]["content"].as_str().unwrap_or("");
|
||||
assert!(
|
||||
system.contains("ROOT_HINT_MARKER_11"),
|
||||
"system prompt missing root hint: {system}"
|
||||
);
|
||||
assert!(
|
||||
system.contains("SUB_HINT_MARKER_22"),
|
||||
"system prompt missing sub hint: {system}"
|
||||
);
|
||||
let root_pos = system.find("ROOT_HINT_MARKER_11").unwrap();
|
||||
let sub_pos = system.find("SUB_HINT_MARKER_22").unwrap();
|
||||
assert!(
|
||||
root_pos < sub_pos,
|
||||
"root hint should appear before sub hint in system prompt"
|
||||
);
|
||||
h.shutdown().await;
|
||||
}
|
||||
|
||||
/// ~/AGENTS.md (global) is loaded before CWD AGENTS.md when HOME is set.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn global_agents_md_loaded() {
|
||||
let home_tmp = tempfile::TempDir::new().unwrap();
|
||||
let cwd_tmp = tempfile::TempDir::new().unwrap();
|
||||
std::fs::write(home_tmp.path().join("AGENTS.md"), "GLOBAL_HINT_MARKER_55").unwrap();
|
||||
std::fs::write(cwd_tmp.path().join("AGENTS.md"), "LOCAL_HINT_MARKER_66").unwrap();
|
||||
|
||||
let llm = spawn_capturing_llm(vec![openai_text("done")]).await;
|
||||
let mut h =
|
||||
Harness::spawn_with_env(&llm.url, &[("HOME", home_tmp.path().to_str().unwrap())]).await;
|
||||
let sid = init_session(&mut h, cwd_tmp.path().to_str().unwrap()).await;
|
||||
|
||||
let p = h
|
||||
.send(
|
||||
"session/prompt",
|
||||
json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}),
|
||||
)
|
||||
.await;
|
||||
let _ = h.recv_until(|v| v["id"] == json!(p)).await;
|
||||
|
||||
let captured = llm.captured.lock().await;
|
||||
assert!(!captured.is_empty(), "no LLM request captured");
|
||||
let system = captured[0]["messages"][0]["content"].as_str().unwrap_or("");
|
||||
assert!(
|
||||
system.contains("GLOBAL_HINT_MARKER_55"),
|
||||
"system prompt missing global hint: {system}"
|
||||
);
|
||||
assert!(
|
||||
system.contains("LOCAL_HINT_MARKER_66"),
|
||||
"system prompt missing local hint: {system}"
|
||||
);
|
||||
let global_pos = system.find("GLOBAL_HINT_MARKER_55").unwrap();
|
||||
let local_pos = system.find("LOCAL_HINT_MARKER_66").unwrap();
|
||||
assert!(
|
||||
global_pos < local_pos,
|
||||
"global hint should appear before local hint in system prompt"
|
||||
);
|
||||
h.shutdown().await;
|
||||
}
|
||||
|
||||
/// Global skills from ~/.agents/skills/ are loaded; project-level wins on name conflict.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn global_skills_loaded_and_project_wins() {
|
||||
let home_tmp = tempfile::TempDir::new().unwrap();
|
||||
let cwd_tmp = tempfile::TempDir::new().unwrap();
|
||||
|
||||
let global_only_dir = home_tmp.path().join(".agents/skills/global-only");
|
||||
std::fs::create_dir_all(&global_only_dir).unwrap();
|
||||
std::fs::write(
|
||||
global_only_dir.join("SKILL.md"),
|
||||
"---\nname: global-only\ndescription: A global skill\n---\nGLOBAL_SKILL_BODY_88\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let global_shared_dir = home_tmp.path().join(".agents/skills/shared-name");
|
||||
std::fs::create_dir_all(&global_shared_dir).unwrap();
|
||||
std::fs::write(
|
||||
global_shared_dir.join("SKILL.md"),
|
||||
"---\nname: shared-name\ndescription: Global version\n---\nGLOBAL_SHARED_BODY_LOSE\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let project_shared_dir = cwd_tmp.path().join(".agents/skills/shared-name");
|
||||
std::fs::create_dir_all(&project_shared_dir).unwrap();
|
||||
std::fs::write(
|
||||
project_shared_dir.join("SKILL.md"),
|
||||
"---\nname: shared-name\ndescription: Project version\n---\nPROJECT_SHARED_BODY_WIN\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let llm = spawn_capturing_llm(vec![openai_text("done")]).await;
|
||||
let mut h =
|
||||
Harness::spawn_with_env(&llm.url, &[("HOME", home_tmp.path().to_str().unwrap())]).await;
|
||||
let sid = init_session(&mut h, cwd_tmp.path().to_str().unwrap()).await;
|
||||
|
||||
let p = h
|
||||
.send(
|
||||
"session/prompt",
|
||||
json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}),
|
||||
)
|
||||
.await;
|
||||
let _ = h.recv_until(|v| v["id"] == json!(p)).await;
|
||||
|
||||
let captured = llm.captured.lock().await;
|
||||
assert!(!captured.is_empty(), "no LLM request captured");
|
||||
let system = captured[0]["messages"][0]["content"].as_str().unwrap_or("");
|
||||
assert!(
|
||||
system.contains("global-only"),
|
||||
"system prompt missing global-only skill name: {system}"
|
||||
);
|
||||
assert!(
|
||||
system.contains("GLOBAL_SKILL_BODY_88"),
|
||||
"system prompt missing global-only skill body: {system}"
|
||||
);
|
||||
assert!(
|
||||
system.contains("PROJECT_SHARED_BODY_WIN"),
|
||||
"system prompt missing project skill body: {system}"
|
||||
);
|
||||
assert!(
|
||||
!system.contains("GLOBAL_SHARED_BODY_LOSE"),
|
||||
"system prompt should NOT contain shadowed global skill body: {system}"
|
||||
);
|
||||
h.shutdown().await;
|
||||
}
|
||||
Reference in New Issue
Block a user