Skip to main content

meerkat_core/
prompt.rs

1//! System prompt configuration and AGENTS.md support
2//!
3//! Provides configurable system prompts with support for:
4//! - Default system prompt
5//! - Custom system prompt override
6//! - AGENTS.md file injection (global + project)
7//!
8//! AGENTS.md discovery:
9//! - Global: ~/.rkat/AGENTS.md
10//! - Project: ./AGENTS.md or ./.rkat/AGENTS.md
11//!
12//! The final prompt is composed as:
13//! 1. System prompt (custom or default)
14//! 2. AGENTS.md content (if found), wrapped in a marker
15
16#[cfg(target_arch = "wasm32")]
17use crate::tokio;
18use std::path::{Path, PathBuf};
19
20/// Default system prompt for Meerkat agents
21pub const DEFAULT_SYSTEM_PROMPT: &str = r"You are an autonomous agent. Your task is to accomplish the user's goal by systematically using the tools available to you.
22
23# Core Behavior
24- Break complex tasks into steps and execute them one by one.
25- Use tools to gather information, take actions, and verify results.
26- When multiple tool calls are independent, execute them in parallel.
27- If a tool call fails, analyze the error and try alternative approaches.
28- Continue working until the task is complete or you determine it cannot be completed.
29
30# Decision Making
31- Act on the information you have. Make reasonable assumptions when necessary.
32- If critical information is missing and no tool can provide it, state what you need and why.
33- Prioritize correctness over speed. Verify your work when possible.
34
35# Output
36- When the task is complete, provide a clear summary of what was accomplished.
37- If the task cannot be completed, explain what blocked progress and what was attempted.";
38
39/// Maximum size for AGENTS.md files (32 KiB, matching Codex default)
40pub const AGENTS_MD_MAX_BYTES: usize = 32 * 1024;
41
42/// Configuration for system prompt composition
43#[derive(Debug, Clone, Default)]
44pub struct SystemPromptConfig {
45    /// Custom system prompt (overrides default if set)
46    pub system_prompt: Option<String>,
47    /// Whether to load AGENTS.md files
48    pub load_agents_md: bool,
49    /// Custom path to global AGENTS.md (defaults to ~/.rkat/AGENTS.md)
50    pub global_agents_md_path: Option<PathBuf>,
51    /// Custom path to project AGENTS.md (defaults to ./AGENTS.md or ./.rkat/AGENTS.md)
52    pub project_agents_md_path: Option<PathBuf>,
53}
54
55impl SystemPromptConfig {
56    /// Create a new config with defaults
57    pub fn new() -> Self {
58        Self {
59            system_prompt: None,
60            load_agents_md: true,
61            global_agents_md_path: None,
62            project_agents_md_path: None,
63        }
64    }
65
66    /// Set a custom system prompt
67    pub fn with_system_prompt(mut self, prompt: impl Into<String>) -> Self {
68        self.system_prompt = Some(prompt.into());
69        self
70    }
71
72    /// Disable AGENTS.md loading
73    pub fn without_agents_md(mut self) -> Self {
74        self.load_agents_md = false;
75        self
76    }
77
78    /// Set custom global AGENTS.md path
79    pub fn with_global_agents_md(mut self, path: impl Into<PathBuf>) -> Self {
80        self.global_agents_md_path = Some(path.into());
81        self
82    }
83
84    /// Set custom project AGENTS.md path
85    pub fn with_project_agents_md(mut self, path: impl Into<PathBuf>) -> Self {
86        self.project_agents_md_path = Some(path.into());
87        self
88    }
89
90    /// Compose the final system prompt
91    pub async fn compose(&self) -> String {
92        let base = self
93            .system_prompt
94            .as_deref()
95            .unwrap_or(DEFAULT_SYSTEM_PROMPT);
96
97        if !self.load_agents_md {
98            return base.to_string();
99        }
100
101        let mut parts = vec![base.to_string()];
102
103        // Load global AGENTS.md
104        if let Some(content) = self.load_global_agents_md().await {
105            parts.push(format!(
106                "\n# Project Instructions (from global AGENTS.md)\n\n{content}"
107            ));
108        }
109
110        // Load project AGENTS.md
111        if let Some(content) = self.load_project_agents_md().await {
112            parts.push(format!(
113                "\n# Project Instructions (from AGENTS.md)\n\n{content}"
114            ));
115        }
116
117        parts.join("\n")
118    }
119
120    async fn load_global_agents_md(&self) -> Option<String> {
121        match self.global_agents_md_path.as_deref() {
122            Some(path) => load_agents_md_file(path).await,
123            None => {
124                let path = default_global_agents_md_path()?;
125                load_agents_md_file(&path).await
126            }
127        }
128    }
129
130    async fn load_project_agents_md(&self) -> Option<String> {
131        if let Some(path) = self.project_agents_md_path.as_deref() {
132            return load_agents_md_file(path).await;
133        }
134
135        let cwd = std::env::current_dir().ok()?;
136
137        for candidate in [cwd.join("AGENTS.md"), cwd.join(".rkat/AGENTS.md")] {
138            if let Some(content) = load_agents_md_file(&candidate).await {
139                return Some(content);
140            }
141        }
142
143        None
144    }
145}
146
147/// Get the default global AGENTS.md path: ~/.rkat/AGENTS.md
148pub fn default_global_agents_md_path() -> Option<PathBuf> {
149    std::env::var_os("HOME")
150        .map(PathBuf::from)
151        .map(|h| h.join(".rkat/AGENTS.md"))
152}
153
154/// Find project AGENTS.md in current directory
155/// Checks: ./AGENTS.md, ./.rkat/AGENTS.md
156pub fn find_project_agents_md() -> Option<PathBuf> {
157    let cwd = std::env::current_dir().ok()?;
158    find_project_agents_md_in(&cwd)
159}
160
161/// Find project AGENTS.md in a specific directory
162/// Checks: <dir>/AGENTS.md, <dir>/.rkat/AGENTS.md
163pub fn find_project_agents_md_in(dir: &Path) -> Option<PathBuf> {
164    // Check ./AGENTS.md first
165    let root_path = dir.join("AGENTS.md");
166    if root_path.exists() {
167        return Some(root_path);
168    }
169
170    // Check ./.rkat/AGENTS.md
171    let meerkat_path = dir.join(".rkat/AGENTS.md");
172    if meerkat_path.exists() {
173        return Some(meerkat_path);
174    }
175
176    None
177}
178
179/// Load an AGENTS.md file, respecting size limits
180async fn load_agents_md_file(path: &Path) -> Option<String> {
181    if !tokio::fs::try_exists(path).await.ok()? {
182        return None;
183    }
184
185    let content = tokio::fs::read_to_string(path).await.ok()?;
186
187    // Skip empty files
188    let trimmed = content.trim();
189    if trimmed.is_empty() {
190        return None;
191    }
192
193    // Enforce size limit
194    if content.len() > AGENTS_MD_MAX_BYTES {
195        // Truncate to limit
196        let truncated: String = content.chars().take(AGENTS_MD_MAX_BYTES).collect();
197        return Some(truncated);
198    }
199
200    Some(content)
201}
202
203#[cfg(test)]
204#[allow(clippy::unwrap_used, clippy::expect_used)]
205mod tests {
206    use super::*;
207    use tempfile::TempDir;
208
209    #[tokio::test]
210    async fn test_default_prompt_used_when_no_override() {
211        let config = SystemPromptConfig::new().without_agents_md();
212        let prompt = config.compose().await;
213        assert_eq!(prompt, DEFAULT_SYSTEM_PROMPT);
214    }
215
216    #[tokio::test]
217    async fn test_custom_prompt_overrides_default() {
218        let config = SystemPromptConfig::new()
219            .with_system_prompt("Custom prompt")
220            .without_agents_md();
221        let prompt = config.compose().await;
222        assert_eq!(prompt, "Custom prompt");
223    }
224
225    #[tokio::test]
226    async fn test_agents_md_disabled() {
227        let temp = TempDir::new().unwrap();
228        let agents_path = temp.path().join("AGENTS.md");
229        tokio::fs::write(&agents_path, "# Should not appear")
230            .await
231            .unwrap();
232
233        let config = SystemPromptConfig::new()
234            .with_project_agents_md(&agents_path)
235            .without_agents_md();
236
237        let prompt = config.compose().await;
238        assert!(!prompt.contains("Should not appear"));
239    }
240
241    #[tokio::test]
242    async fn test_project_agents_md_injected() {
243        let temp = TempDir::new().unwrap();
244        let agents_path = temp.path().join("AGENTS.md");
245        tokio::fs::write(&agents_path, "# Build Instructions\nRun `make build`")
246            .await
247            .unwrap();
248
249        let config = SystemPromptConfig::new().with_project_agents_md(&agents_path);
250
251        let prompt = config.compose().await;
252        assert!(prompt.contains(DEFAULT_SYSTEM_PROMPT));
253        assert!(prompt.contains("# Project Instructions (from AGENTS.md)"));
254        assert!(prompt.contains("# Build Instructions"));
255        assert!(prompt.contains("Run `make build`"));
256    }
257
258    #[tokio::test]
259    async fn test_global_agents_md_injected() {
260        let temp = TempDir::new().unwrap();
261        let global_path = temp.path().join("global-agents.md");
262        tokio::fs::write(&global_path, "# Global rules\nAlways be nice")
263            .await
264            .unwrap();
265
266        let config = SystemPromptConfig::new().with_global_agents_md(&global_path);
267
268        let prompt = config.compose().await;
269        assert!(prompt.contains("# Project Instructions (from global AGENTS.md)"));
270        assert!(prompt.contains("# Global rules"));
271    }
272
273    #[tokio::test]
274    async fn test_both_global_and_project_agents_md() {
275        let temp = TempDir::new().unwrap();
276
277        let global_path = temp.path().join("global.md");
278        tokio::fs::write(&global_path, "Global instructions")
279            .await
280            .unwrap();
281
282        let project_path = temp.path().join("project.md");
283        tokio::fs::write(&project_path, "Project instructions")
284            .await
285            .unwrap();
286
287        let config = SystemPromptConfig::new()
288            .with_global_agents_md(&global_path)
289            .with_project_agents_md(&project_path);
290
291        let prompt = config.compose().await;
292
293        // Global should come before project
294        let global_pos = prompt.find("Global instructions").unwrap();
295        let project_pos = prompt.find("Project instructions").unwrap();
296        assert!(global_pos < project_pos);
297    }
298
299    #[tokio::test]
300    async fn test_empty_agents_md_ignored() {
301        let temp = TempDir::new().unwrap();
302        let agents_path = temp.path().join("AGENTS.md");
303        tokio::fs::write(&agents_path, "   \n\n  ").await.unwrap(); // whitespace only
304
305        let config = SystemPromptConfig::new().with_project_agents_md(&agents_path);
306
307        let prompt = config.compose().await;
308        assert!(!prompt.contains("Project Instructions"));
309    }
310
311    #[tokio::test]
312    async fn test_agents_md_size_limit() {
313        let temp = TempDir::new().unwrap();
314        let agents_path = temp.path().join("AGENTS.md");
315
316        // Create content larger than limit
317        let large_content = "x".repeat(AGENTS_MD_MAX_BYTES + 1000);
318        tokio::fs::write(&agents_path, &large_content)
319            .await
320            .unwrap();
321
322        let config = SystemPromptConfig::new().with_project_agents_md(&agents_path);
323
324        let prompt = config.compose().await;
325
326        // Should be truncated
327        let agents_section_start = prompt.find("# Project Instructions").unwrap();
328        let agents_content = &prompt[agents_section_start..];
329        assert!(agents_content.len() <= AGENTS_MD_MAX_BYTES + 100); // some buffer for header
330    }
331
332    #[tokio::test]
333    async fn test_find_project_agents_md_root() {
334        let temp = TempDir::new().unwrap();
335        let agents_path = temp.path().join("AGENTS.md");
336        tokio::fs::write(&agents_path, "content").await.unwrap();
337
338        let found = find_project_agents_md_in(temp.path());
339        assert_eq!(found, Some(agents_path));
340    }
341
342    #[tokio::test]
343    async fn test_find_project_agents_md_in_meerkat_dir() {
344        let temp = TempDir::new().unwrap();
345        let meerkat_dir = temp.path().join(".rkat");
346        tokio::fs::create_dir_all(&meerkat_dir).await.unwrap();
347        let agents_path = meerkat_dir.join("AGENTS.md");
348        tokio::fs::write(&agents_path, "content").await.unwrap();
349
350        let found = find_project_agents_md_in(temp.path());
351        assert_eq!(found, Some(agents_path));
352    }
353
354    #[tokio::test]
355    async fn test_find_project_agents_md_root_takes_precedence() {
356        let temp = TempDir::new().unwrap();
357
358        // Create both
359        let root_path = temp.path().join("AGENTS.md");
360        tokio::fs::write(&root_path, "root content").await.unwrap();
361
362        let meerkat_dir = temp.path().join(".rkat");
363        tokio::fs::create_dir_all(&meerkat_dir).await.unwrap();
364        tokio::fs::write(meerkat_dir.join("AGENTS.md"), "test content")
365            .await
366            .unwrap();
367
368        // Root should win
369        let found = find_project_agents_md_in(temp.path());
370        assert_eq!(found, Some(root_path));
371    }
372
373    #[test]
374    fn test_missing_agents_md_returns_none() {
375        let temp = TempDir::new().unwrap();
376        let found = find_project_agents_md_in(temp.path());
377        assert_eq!(found, None);
378    }
379}