use meerkat_core::{Config, SystemPromptConfig, prompt::normalize_agents_md_content};
use std::path::Path;
pub async fn assemble_system_prompt(
config: &Config,
per_request_prompt: Option<&str>,
context_root: Option<&Path>,
extra_sections: &[&str],
tool_usage_instructions: &str,
) -> String {
if let Some(prompt) = per_request_prompt {
return append_sections(prompt, extra_sections, &[], tool_usage_instructions);
}
let mut spc = SystemPromptConfig::new();
if let Some(ref path) = config.agent.system_prompt_file {
match tokio::fs::read_to_string(path).await {
Ok(content) => spc.system_prompt = Some(content),
Err(e) => tracing::warn!("system_prompt_file not readable: {}: {e}", path.display()),
}
}
if spc.system_prompt.is_none()
&& let Some(ref prompt) = config.agent.system_prompt
{
spc.system_prompt = Some(prompt.clone());
}
if let Some(context) = context_root
&& let Some(content) = load_project_agents_md_in(context).await
{
spc = spc.with_project_agents_md_content(content);
}
let base = spc.compose().await;
let config_tool_sections: Vec<&str> = config
.agent
.tool_instructions
.as_deref()
.into_iter()
.collect();
append_sections(
&base,
extra_sections,
&config_tool_sections,
tool_usage_instructions,
)
}
async fn load_project_agents_md_in(dir: &Path) -> Option<String> {
for candidate in [dir.join("AGENTS.md"), dir.join(".rkat/AGENTS.md")] {
if let Some(content) = load_agents_md_file(&candidate).await {
return Some(content);
}
}
None
}
async fn load_agents_md_file(path: &Path) -> Option<String> {
if !tokio::fs::try_exists(path).await.ok()? {
return None;
}
let content = tokio::fs::read_to_string(path).await.ok()?;
normalize_agents_md_content(&content)
}
fn append_sections(
base: &str,
extra_sections: &[&str],
config_tool_sections: &[&str],
tool_instructions: &str,
) -> String {
let mut prompt = base.to_string();
for section in extra_sections {
if !section.is_empty() {
prompt.push_str("\n\n");
prompt.push_str(section);
}
}
for section in config_tool_sections {
if !section.is_empty() {
prompt.push_str("\n\n");
prompt.push_str(section);
}
}
if !tool_instructions.is_empty() {
prompt.push_str("\n\n");
prompt.push_str(tool_instructions);
}
prompt
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use meerkat_core::prompt::{AGENTS_MD_MAX_BYTES, DEFAULT_SYSTEM_PROMPT};
use std::path::PathBuf;
use tempfile::TempDir;
fn default_config() -> Config {
Config::default()
}
#[tokio::test]
async fn test_per_request_override_wins() {
let config = default_config();
let result =
assemble_system_prompt(&config, Some("Per-request prompt"), None, &[], "").await;
assert_eq!(result, "Per-request prompt");
}
#[tokio::test]
async fn test_per_request_override_skips_config_fields() {
let mut config = default_config();
config.agent.system_prompt = Some("Config inline prompt".to_string());
config.agent.tool_instructions = Some("Config tool instructions".to_string());
let result =
assemble_system_prompt(&config, Some("Per-request prompt"), None, &[], "").await;
assert_eq!(result, "Per-request prompt");
assert!(!result.contains("Config inline"));
assert!(!result.contains("Config tool instructions"));
}
#[tokio::test]
async fn test_per_request_override_still_appends_dispatcher_tools() {
let config = default_config();
let result = assemble_system_prompt(
&config,
Some("Per-request prompt"),
None,
&[],
"Dispatcher tool instructions",
)
.await;
assert!(result.starts_with("Per-request prompt"));
assert!(result.contains("Dispatcher tool instructions"));
}
#[tokio::test]
async fn test_config_file_override() {
let temp = TempDir::new().unwrap();
let file_path = temp.path().join("prompt.txt");
tokio::fs::write(&file_path, "File-based prompt")
.await
.unwrap();
let mut config = default_config();
config.agent.system_prompt_file = Some(file_path);
let result = assemble_system_prompt(&config, None, None, &[], "").await;
assert!(result.contains("File-based prompt"));
assert!(!result.contains(DEFAULT_SYSTEM_PROMPT));
}
#[tokio::test]
async fn test_config_file_beats_inline() {
let temp = TempDir::new().unwrap();
let file_path = temp.path().join("prompt.txt");
tokio::fs::write(&file_path, "File-based prompt")
.await
.unwrap();
let mut config = default_config();
config.agent.system_prompt_file = Some(file_path);
config.agent.system_prompt = Some("Inline prompt".to_string());
let result = assemble_system_prompt(&config, None, None, &[], "").await;
assert!(result.contains("File-based prompt"));
assert!(!result.contains("Inline prompt"));
}
#[tokio::test]
async fn test_config_file_unreadable_falls_through() {
let mut config = default_config();
config.agent.system_prompt_file = Some(PathBuf::from("/nonexistent/path/prompt.txt"));
config.agent.system_prompt = Some("Inline fallback".to_string());
let result = assemble_system_prompt(&config, None, None, &[], "").await;
assert!(result.contains("Inline fallback"));
}
#[tokio::test]
async fn test_config_inline_override() {
let mut config = default_config();
config.agent.system_prompt = Some("Inline prompt".to_string());
let result = assemble_system_prompt(&config, None, None, &[], "").await;
assert!(result.contains("Inline prompt"));
assert!(!result.contains(DEFAULT_SYSTEM_PROMPT));
}
#[tokio::test]
async fn test_context_root_agents_md_appended_to_config_inline() {
let temp = TempDir::new().unwrap();
let agents_path = temp.path().join("AGENTS.md");
tokio::fs::write(&agents_path, "Context root instructions")
.await
.unwrap();
let mut config = default_config();
config.agent.system_prompt = Some("Inline prompt".to_string());
let result = assemble_system_prompt(&config, None, Some(temp.path()), &[], "").await;
assert!(result.contains("Inline prompt"));
assert!(result.contains("Context root instructions"));
assert!(!result.contains(DEFAULT_SYSTEM_PROMPT));
let inline_pos = result.find("Inline prompt").unwrap();
let agents_pos = result.find("Context root instructions").unwrap();
assert!(inline_pos < agents_pos);
}
#[tokio::test]
async fn test_context_root_agents_md_size_limit_owned_by_prompt_assembly() {
let temp = TempDir::new().unwrap();
let agents_path = temp.path().join("AGENTS.md");
tokio::fs::write(&agents_path, "x".repeat(AGENTS_MD_MAX_BYTES + 1000))
.await
.unwrap();
let config = default_config();
let result = assemble_system_prompt(&config, None, Some(temp.path()), &[], "").await;
let agents_section_start = result.find("# Project Instructions").unwrap();
let agents_content = &result[agents_section_start..];
assert!(agents_content.len() <= AGENTS_MD_MAX_BYTES + 100);
}
#[tokio::test]
async fn test_context_root_agents_md_falls_back_when_root_file_is_unusable() {
let temp = TempDir::new().unwrap();
let agents_path = temp.path().join("AGENTS.md");
tokio::fs::write(&agents_path, [0xff, 0xfe]).await.unwrap();
let rkat_dir = temp.path().join(".rkat");
tokio::fs::create_dir_all(&rkat_dir).await.unwrap();
tokio::fs::write(rkat_dir.join("AGENTS.md"), "Fallback instructions")
.await
.unwrap();
let config = default_config();
let result = assemble_system_prompt(&config, None, Some(temp.path()), &[], "").await;
assert!(result.contains("Fallback instructions"));
}
#[tokio::test]
async fn test_default_prompt_when_no_overrides() {
let config = default_config();
let result = assemble_system_prompt(&config, None, None, &[], "").await;
assert!(result.contains(DEFAULT_SYSTEM_PROMPT));
}
#[tokio::test]
async fn test_config_tool_instructions_appended() {
let mut config = default_config();
config.agent.tool_instructions = Some("Use tools carefully".to_string());
let result = assemble_system_prompt(&config, None, None, &[], "").await;
assert!(result.contains("Use tools carefully"));
}
#[tokio::test]
async fn test_config_tool_instructions_before_dispatcher() {
let mut config = default_config();
config.agent.tool_instructions = Some("Config tools".to_string());
let result = assemble_system_prompt(&config, None, None, &[], "Dispatcher tools").await;
let config_pos = result.find("Config tools").unwrap();
let dispatcher_pos = result.find("Dispatcher tools").unwrap();
assert!(
config_pos < dispatcher_pos,
"Config tool instructions should come before dispatcher tool instructions"
);
}
#[tokio::test]
async fn test_dispatcher_tool_instructions_appended() {
let config = default_config();
let result =
assemble_system_prompt(&config, None, None, &[], "Dispatcher tool instructions").await;
assert!(result.contains("Dispatcher tool instructions"));
}
#[tokio::test]
async fn test_extra_sections_appended() {
let config = default_config();
let result = assemble_system_prompt(
&config,
None,
None,
&["## Available Skills\n- /task-workflow"],
"",
)
.await;
assert!(result.contains("## Available Skills"));
assert!(result.contains("/task-workflow"));
}
#[tokio::test]
async fn test_extra_sections_before_tool_instructions() {
let mut config = default_config();
config.agent.tool_instructions = Some("Config tools".to_string());
let result =
assemble_system_prompt(&config, None, None, &["Skills section"], "Dispatcher tools")
.await;
let skills_pos = result.find("Skills section").unwrap();
let config_tools_pos = result.find("Config tools").unwrap();
let dispatcher_pos = result.find("Dispatcher tools").unwrap();
assert!(skills_pos < config_tools_pos);
assert!(config_tools_pos < dispatcher_pos);
}
#[tokio::test]
async fn test_empty_extra_sections_no_double_newlines() {
let config = default_config();
let result = assemble_system_prompt(&config, None, None, &["", ""], "").await;
assert!(!result.contains("\n\n\n\n"));
}
#[tokio::test]
async fn test_full_precedence_chain() {
let mut config = default_config();
config.agent.system_prompt = Some("Inline base".to_string());
config.agent.tool_instructions = Some("Config tools".to_string());
let result = assemble_system_prompt(
&config,
None,
None,
&["Skills inventory"],
"Dispatcher tools",
)
.await;
assert!(result.contains("Inline base"));
assert!(result.contains("Skills inventory"));
assert!(result.contains("Config tools"));
assert!(result.contains("Dispatcher tools"));
let base_pos = result.find("Inline base").unwrap();
let skills_pos = result.find("Skills inventory").unwrap();
let config_pos = result.find("Config tools").unwrap();
let dispatcher_pos = result.find("Dispatcher tools").unwrap();
assert!(base_pos < skills_pos);
assert!(skills_pos < config_pos);
assert!(config_pos < dispatcher_pos);
}
#[tokio::test]
async fn test_additional_instructions_appear_after_skills_before_tool_instructions() {
let mut config = default_config();
config.agent.tool_instructions = Some("Config tools".to_string());
let result = assemble_system_prompt(
&config,
None,
None,
&[
"Skills section",
"Additional instruction 1",
"Additional instruction 2",
],
"Dispatcher tools",
)
.await;
let skills_pos = result.find("Skills section").unwrap();
let instr1_pos = result.find("Additional instruction 1").unwrap();
let instr2_pos = result.find("Additional instruction 2").unwrap();
let config_pos = result.find("Config tools").unwrap();
let dispatcher_pos = result.find("Dispatcher tools").unwrap();
assert!(skills_pos < instr1_pos, "instructions after skills");
assert!(instr1_pos < instr2_pos, "instructions preserve order");
assert!(instr2_pos < config_pos, "instructions before config tools");
assert!(
config_pos < dispatcher_pos,
"config tools before dispatcher"
);
}
#[tokio::test]
async fn test_additional_instructions_not_after_tool_instructions() {
let config = default_config();
let result = assemble_system_prompt(
&config,
None,
None,
&["My additional instruction"],
"Dispatcher tools block",
)
.await;
let instruction_pos = result.find("My additional instruction").unwrap();
let dispatcher_pos = result.find("Dispatcher tools block").unwrap();
assert!(
instruction_pos < dispatcher_pos,
"additional instructions must NOT appear after dispatcher tool instructions"
);
}
}