feat(sprout-agent): load AGENTS.md and SKILL.md into system prompt (#762)

This commit is contained in:
Will Pfleger
2026-05-29 14:44:33 -04:00
committed by GitHub
parent 85861f33fe
commit f34a21d32f
7 changed files with 1028 additions and 18 deletions
+2 -1
View File
@@ -21,6 +21,7 @@ const ERROR_REFLECTION_SUFFIX: &str =
pub struct RunCtx<'a> { pub struct RunCtx<'a> {
pub cfg: &'a Config, pub cfg: &'a Config,
pub session_id: &'a str, pub session_id: &'a str,
pub system_prompt: &'a str,
pub llm: &'a Llm, pub llm: &'a Llm,
pub mcp: &'a Arc<McpRegistry>, pub mcp: &'a Arc<McpRegistry>,
pub wire: &'a WireSender, pub wire: &'a WireSender,
@@ -73,7 +74,7 @@ impl RunCtx<'_> {
let response = tokio::select! { let response = tokio::select! {
biased; biased;
_ = self.cancel.changed() => return Ok(StopReason::Cancelled), _ = 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() { if !response.text.is_empty() {
+2
View File
@@ -75,6 +75,7 @@ pub struct Config {
pub anthropic_api_version: String, pub anthropic_api_version: String,
/// OpenAI endpoint selection. See [`OpenAiApi`]. /// OpenAI endpoint selection. See [`OpenAiApi`].
pub openai_api: OpenAiApi, pub openai_api: OpenAiApi,
pub hints_enabled: bool,
} }
impl Config { impl Config {
@@ -156,6 +157,7 @@ impl Config {
)?), )?),
stop_max_rejections: parse_env("SPROUT_AGENT_STOP_MAX_REJECTIONS", 3u32)?, stop_max_rejections: parse_env("SPROUT_AGENT_STOP_MAX_REJECTIONS", 3u32)?,
hook_servers: parse_hook_servers_env("MCP_HOOK_SERVERS"), hook_servers: parse_hook_servers_env("MCP_HOOK_SERVERS"),
hints_enabled: parse_env("SPROUT_AGENT_NO_HINTS", 0u8)? == 0,
}; };
cfg.validate()?; cfg.validate()?;
Ok(cfg) Ok(cfg)
+525
View File
@@ -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");
}
}
+17
View File
@@ -3,6 +3,7 @@ mod agent;
pub mod auth; pub mod auth;
mod config; mod config;
mod handoff; mod handoff;
mod hints;
mod llm; mod llm;
mod mcp; mod mcp;
mod types; mod types;
@@ -41,6 +42,7 @@ struct Session {
original_task: Option<String>, original_task: Option<String>,
handoff_count: usize, handoff_count: usize,
stop_rejections: u32, stop_rejections: u32,
effective_system_prompt: Arc<str>,
} }
fn die(msg: String) -> ! { fn die(msg: String) -> ! {
@@ -253,6 +255,16 @@ async fn session_new(app: &Arc<App>, id: Value, params: Value, wire_tx: &WireSen
.await; .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 { let mcp = match McpRegistry::spawn_all(&app.cfg, &p.mcp_servers, &p.cwd).await {
Ok(m) => Arc::new(m), Ok(m) => Arc::new(m),
Err(e) => return reject(wire_tx, id, e.json_rpc_code(), &e.to_string()).await, 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, original_task: None,
handoff_count: 0, handoff_count: 0,
stop_rejections: 0, stop_rejections: 0,
effective_system_prompt,
}, },
); );
drop(sessions); drop(sessions);
@@ -323,6 +336,7 @@ async fn run_prompt(app: Arc<App>, id: Value, params: Value, wire_tx: WireSender
mut handoff_count, mut handoff_count,
mut stop_rejections, mut stop_rejections,
mut cancel_rx, mut cancel_rx,
effective_system_prompt,
) = match acquire_session(&app, &p.session_id).await { ) = match acquire_session(&app, &p.session_id).await {
Ok(v) => v, Ok(v) => v,
Err(reason) => { Err(reason) => {
@@ -338,6 +352,7 @@ async fn run_prompt(app: Arc<App>, id: Value, params: Value, wire_tx: WireSender
let mut ctx = RunCtx { let mut ctx = RunCtx {
cfg: &app.cfg, cfg: &app.cfg,
session_id: &sid, session_id: &sid,
system_prompt: &effective_system_prompt,
llm: &app.llm, llm: &app.llm,
mcp: &mcp, mcp: &mcp,
wire: &wire_tx, wire: &wire_tx,
@@ -379,6 +394,7 @@ async fn acquire_session(
usize, usize,
u32, u32,
watch::Receiver<bool>, watch::Receiver<bool>,
Arc<str>,
), ),
&'static str, &'static str,
> { > {
@@ -398,6 +414,7 @@ async fn acquire_session(
s.handoff_count, s.handoff_count,
s.stop_rejections, s.stop_rejections,
rx, rx,
Arc::clone(&s.effective_system_prompt),
)) ))
} }
+38 -16
View File
@@ -56,13 +56,14 @@ impl Llm {
pub async fn complete( pub async fn complete(
&self, &self,
cfg: &Config, cfg: &Config,
system_prompt: &str,
history: &[HistoryItem], history: &[HistoryItem],
tools: &[ToolDef], tools: &[ToolDef],
) -> Result<LlmResponse, AgentError> { ) -> Result<LlmResponse, AgentError> {
match cfg.provider { match cfg.provider {
Provider::Anthropic => { Provider::Anthropic => {
let v = self let v = self
.post_anthropic(cfg, &anthropic_body(cfg, history, tools)) .post_anthropic(cfg, &anthropic_body(cfg, system_prompt, history, tools))
.await?; .await?;
parse_anthropic(v) parse_anthropic(v)
} }
@@ -70,12 +71,12 @@ impl Llm {
self.openai_request(cfg, |use_responses| { self.openai_request(cfg, |use_responses| {
if use_responses { if use_responses {
( (
responses_body(cfg, history, tools), responses_body(cfg, system_prompt, history, tools),
parse_responses as OpenAiParse, parse_responses as OpenAiParse,
) )
} else { } else {
( (
openai_body(cfg, history, tools), openai_body(cfg, system_prompt, history, tools),
parse_openai as OpenAiParse, 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 messages: Vec<Value> = Vec::new();
let mut pending: Vec<Value> = Vec::new(); let mut pending: Vec<Value> = Vec::new();
let flush = |out: &mut Vec<Value>, p: &mut Vec<Value>| { 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(); .collect();
let mut body = json!({ "model": cfg.model, "max_tokens": cfg.max_output_tokens, 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() { if !tools_json.is_empty() {
body["tools"] = Value::Array(tools_json); body["tools"] = Value::Array(tools_json);
} }
@@ -295,8 +301,13 @@ fn anthropic_tool_result_content(content: &[ToolResultContent]) -> Vec<Value> {
.collect() .collect()
} }
fn openai_body(cfg: &Config, history: &[HistoryItem], tools: &[ToolDef]) -> Value { fn openai_body(
let mut messages: Vec<Value> = vec![json!({ "role": "system", "content": cfg.system_prompt })]; 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"` // Images returned from tool calls ride on a trailing `role:"user"`
// message because OpenAI Chat's `role:"tool"` content is text-only. We // message because OpenAI Chat's `role:"tool"` content is text-only. We
// batch them across a run of adjacent ToolResult items so that all // 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 // "No tool call found for call_id ...". `HistoryItem` ordering already
// guarantees this. // 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()); let mut input: Vec<Value> = Vec::with_capacity(history.len());
for item in history { for item in history {
match item { match item {
@@ -463,7 +479,7 @@ fn responses_body(cfg: &Config, history: &[HistoryItem], tools: &[ToolDef]) -> V
let mut body = json!({ let mut body = json!({
"model": cfg.model, "model": cfg.model,
"instructions": cfg.system_prompt, "instructions": system_prompt,
"max_output_tokens": cfg.max_output_tokens, "max_output_tokens": cfg.max_output_tokens,
"input": input, "input": input,
}); });
@@ -864,6 +880,7 @@ mod tests {
base_url: "http://example.invalid".into(), base_url: "http://example.invalid".into(),
anthropic_api_version: "2023-06-01".into(), anthropic_api_version: "2023-06-01".into(),
openai_api: OpenAiApi::Chat, openai_api: OpenAiApi::Chat,
hints_enabled: true,
} }
} }
@@ -894,7 +911,7 @@ mod tests {
#[test] #[test]
fn anthropic_tool_result_preserves_image_block() { 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"]; let content = &body["messages"][2]["content"][0]["content"];
assert_eq!(content[0]["type"], "text"); assert_eq!(content[0]["type"], "text");
assert_eq!(content[1]["type"], "image"); assert_eq!(content[1]["type"], "image");
@@ -940,7 +957,12 @@ mod tests {
"properties": {"command": {"type": "string"}}, "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["model"], "model");
assert_eq!(body["instructions"], "system"); assert_eq!(body["instructions"], "system");
assert_eq!(body["max_output_tokens"], 1024); 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 item *must* appear in `input[]` before its matching
// function_call_output, otherwise the API rejects with // function_call_output, otherwise the API rejects with
// "No tool call found for call_id ...". // "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(); let input = body["input"].as_array().unwrap();
// [0] user, [1] assistant text, [2] function_call, [3] function_call_output // [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(); let input = body["input"].as_array().unwrap();
assert_eq!(input.len(), 2); assert_eq!(input.len(), 2);
assert_eq!(input[0]["role"], "user"); assert_eq!(input[0]["role"], "user");
@@ -1016,7 +1038,7 @@ mod tests {
#[test] #[test]
fn responses_body_image_tool_result_attaches_input_image() { 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(); let input = body["input"].as_array().unwrap();
// function_call_output carries the text part; image rides on a // function_call_output carries the text part; image rides on a
// trailing user message as `input_image`. // trailing user message as `input_image`.
@@ -1118,7 +1140,7 @@ mod tests {
#[test] #[test]
fn openai_tool_result_adds_followup_image_user_message() { 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_eq!(body["messages"][3]["role"], "tool");
assert!(body["messages"][3]["content"] assert!(body["messages"][3]["content"]
.as_str() .as_str()
@@ -1189,7 +1211,7 @@ mod tests {
is_error: false, 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(); let messages = body["messages"].as_array().unwrap();
// [0] system, [1] user, [2] assistant(tool_calls), [3] tool A, [4] tool B, [5] user(images) // [0] system, [1] user, [2] assistant(tool_calls), [3] tool A, [4] tool B, [5] user(images)
assert_eq!(messages.len(), 6, "messages: {messages:#?}"); assert_eq!(messages.len(), 6, "messages: {messages:#?}");
+1 -1
View File
@@ -822,7 +822,7 @@ fn valid_name(s: &str) -> bool {
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') .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 { if s.len() <= max {
return s; 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;
}