use crate::SystemPromptOverride;
use meerkat_core::{Config, SystemPromptConfig, prompt::normalize_agents_md_content};
use std::path::Path;
#[derive(Debug, thiserror::Error)]
pub enum PromptAssemblyError {
#[error("configured system_prompt_file '{path}' is not readable: {source}")]
SystemPromptFileUnreadable {
path: String,
source: std::io::Error,
},
#[error("project AGENTS.md '{path}' is not readable: {source}")]
AgentsMdUnreadable {
path: String,
source: std::io::Error,
},
}
pub async fn assemble_system_prompt(
config: &Config,
prompt_override: &SystemPromptOverride,
context_root: Option<&Path>,
extra_sections: &[&str],
tool_usage_instructions: &str,
) -> Result<String, PromptAssemblyError> {
match prompt_override {
SystemPromptOverride::Set(prompt) => {
return Ok(append_sections(
prompt,
extra_sections,
&[],
tool_usage_instructions,
));
}
SystemPromptOverride::Disable => {
let config_tool_sections: Vec<&str> = config
.agent
.tool_instructions
.as_deref()
.into_iter()
.collect();
return Ok(append_sections(
"",
extra_sections,
&config_tool_sections,
tool_usage_instructions,
));
}
SystemPromptOverride::Inherit => {}
}
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(source) => {
return Err(PromptAssemblyError::SystemPromptFileUnreadable {
path: path.display().to_string(),
source,
});
}
}
}
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();
Ok(append_sections(
&base,
extra_sections,
&config_tool_sections,
tool_usage_instructions,
))
}
async fn load_project_agents_md_in(dir: &Path) -> Result<Option<String>, PromptAssemblyError> {
for candidate in [dir.join("AGENTS.md"), dir.join(".rkat/AGENTS.md")] {
if let Some(content) = load_agents_md_file(&candidate).await? {
return Ok(Some(content));
}
}
Ok(None)
}
async fn load_agents_md_file(path: &Path) -> Result<Option<String>, PromptAssemblyError> {
let exists = tokio::fs::try_exists(path).await.map_err(|source| {
PromptAssemblyError::AgentsMdUnreadable {
path: path.display().to_string(),
source,
}
})?;
if !exists {
return Ok(None);
}
let content = tokio::fs::read_to_string(path).await.map_err(|source| {
PromptAssemblyError::AgentsMdUnreadable {
path: path.display().to_string(),
source,
}
})?;
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();
let push_section = |prompt: &mut String, section: &str| {
if section.is_empty() {
return;
}
if !prompt.is_empty() {
prompt.push_str("\n\n");
}
prompt.push_str(section);
};
for section in extra_sections {
push_section(&mut prompt, section);
}
for section in config_tool_sections {
push_section(&mut prompt, section);
}
push_section(&mut prompt, 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()
}
fn set(prompt: &str) -> SystemPromptOverride {
SystemPromptOverride::Set(prompt.to_string())
}
#[tokio::test]
async fn test_per_request_override_wins() {
let config = default_config();
let result = assemble_system_prompt(&config, &set("Per-request prompt"), None, &[], "")
.await
.unwrap();
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, &set("Per-request prompt"), None, &[], "")
.await
.unwrap();
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,
&set("Per-request prompt"),
None,
&[],
"Dispatcher tool instructions",
)
.await
.unwrap();
assert!(result.starts_with("Per-request prompt"));
assert!(result.contains("Dispatcher tool instructions"));
}
#[tokio::test]
async fn test_prompt_override_disable_suppresses_all_sources() {
let temp = TempDir::new().unwrap();
let file_path = temp.path().join("prompt.txt");
tokio::fs::write(&file_path, "File-based prompt")
.await
.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_file = Some(file_path);
config.agent.system_prompt = Some("Inline prompt".to_string());
let result = assemble_system_prompt(
&config,
&SystemPromptOverride::Disable,
Some(temp.path()),
&[],
"Dispatcher tool instructions",
)
.await
.unwrap();
assert!(!result.contains("File-based prompt"));
assert!(!result.contains("Inline prompt"));
assert!(!result.contains("Context root instructions"));
assert!(!result.contains(DEFAULT_SYSTEM_PROMPT));
assert_eq!(result, "Dispatcher tool instructions");
}
#[tokio::test]
async fn test_prompt_override_set_skips_config_only() {
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());
config.agent.tool_instructions = Some("Config tool instructions".to_string());
let result = assemble_system_prompt(
&config,
&set("Per-request prompt"),
Some(temp.path()),
&[],
"Dispatcher tools",
)
.await
.unwrap();
assert!(result.starts_with("Per-request prompt"));
assert!(!result.contains("Inline prompt"));
assert!(!result.contains("Context root instructions"));
assert!(!result.contains(DEFAULT_SYSTEM_PROMPT));
assert!(!result.contains("Config tool instructions"));
assert!(result.contains("Dispatcher tools"));
}
#[tokio::test]
async fn test_prompt_override_inherit_uses_config_and_default() {
let mut config = default_config();
config.agent.system_prompt = Some("Inline prompt".to_string());
let result = assemble_system_prompt(&config, &SystemPromptOverride::Inherit, None, &[], "")
.await
.unwrap();
assert!(result.contains("Inline prompt"));
}
#[test]
fn test_prompt_override_explicitness() {
assert!(!SystemPromptOverride::Inherit.is_explicit());
assert!(set("p").is_explicit());
assert!(SystemPromptOverride::Disable.is_explicit());
assert_eq!(set("p").as_set_prompt(), Some("p"));
assert_eq!(SystemPromptOverride::Inherit.as_set_prompt(), None);
assert_eq!(SystemPromptOverride::Disable.as_set_prompt(), None);
}
#[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, &SystemPromptOverride::Inherit, None, &[], "")
.await
.unwrap();
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, &SystemPromptOverride::Inherit, None, &[], "")
.await
.unwrap();
assert!(result.contains("File-based prompt"));
assert!(!result.contains("Inline prompt"));
}
#[tokio::test]
async fn test_explicit_prompt_file_unreadable_is_error() {
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, &SystemPromptOverride::Inherit, None, &[], "").await;
let err = result.expect_err("unreadable configured system_prompt_file must error");
assert!(matches!(
err,
PromptAssemblyError::SystemPromptFileUnreadable { .. }
));
}
#[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, &SystemPromptOverride::Inherit, None, &[], "")
.await
.unwrap();
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,
&SystemPromptOverride::Inherit,
Some(temp.path()),
&[],
"",
)
.await
.unwrap();
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,
&SystemPromptOverride::Inherit,
Some(temp.path()),
&[],
"",
)
.await
.unwrap();
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_unreadable_is_error_not_silent_fallback() {
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,
&SystemPromptOverride::Inherit,
Some(temp.path()),
&[],
"",
)
.await;
let err = result.expect_err("unreadable AGENTS.md must be a typed fault");
assert!(matches!(
err,
PromptAssemblyError::AgentsMdUnreadable { .. }
));
}
#[tokio::test]
async fn test_context_root_agents_md_absent_is_honest_absence() {
let temp = TempDir::new().unwrap();
let config = default_config();
let result = assemble_system_prompt(
&config,
&SystemPromptOverride::Inherit,
Some(temp.path()),
&[],
"",
)
.await
.unwrap();
assert!(result.contains(DEFAULT_SYSTEM_PROMPT));
}
#[tokio::test]
async fn test_default_prompt_when_no_overrides() {
let config = default_config();
let result = assemble_system_prompt(&config, &SystemPromptOverride::Inherit, None, &[], "")
.await
.unwrap();
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, &SystemPromptOverride::Inherit, None, &[], "")
.await
.unwrap();
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,
&SystemPromptOverride::Inherit,
None,
&[],
"Dispatcher tools",
)
.await
.unwrap();
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,
&SystemPromptOverride::Inherit,
None,
&[],
"Dispatcher tool instructions",
)
.await
.unwrap();
assert!(result.contains("Dispatcher tool instructions"));
}
#[tokio::test]
async fn test_extra_sections_appended() {
let config = default_config();
let result = assemble_system_prompt(
&config,
&SystemPromptOverride::Inherit,
None,
&["## Available Skills\n- /task-workflow"],
"",
)
.await
.unwrap();
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,
&SystemPromptOverride::Inherit,
None,
&["Skills section"],
"Dispatcher tools",
)
.await
.unwrap();
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, &SystemPromptOverride::Inherit, None, &["", ""], "")
.await
.unwrap();
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,
&SystemPromptOverride::Inherit,
None,
&["Skills inventory"],
"Dispatcher tools",
)
.await
.unwrap();
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,
&SystemPromptOverride::Inherit,
None,
&[
"Skills section",
"Additional instruction 1",
"Additional instruction 2",
],
"Dispatcher tools",
)
.await
.unwrap();
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,
&SystemPromptOverride::Inherit,
None,
&["My additional instruction"],
"Dispatcher tools block",
)
.await
.unwrap();
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"
);
}
}