Skip to main content

recursive/
config.rs

1//! Runtime configuration.
2//!
3//! All of these can be overridden via env vars or CLI flags. Sensible
4//! defaults make the binary runnable with just `RECURSIVE_API_KEY` and
5//! `RECURSIVE_MODEL` set.
6
7use std::path::{Path, PathBuf};
8
9use crate::error::{Error, Result};
10
11#[derive(Debug, Clone)]
12pub struct Config {
13    pub workspace: PathBuf,
14    pub api_base: String,
15    pub api_key: Option<String>,
16    pub model: String,
17    pub max_steps: usize,
18    pub temperature: f64,
19    pub system_prompt: String,
20    pub retry_max: usize,
21    pub retry_initial_backoff_secs: u64,
22    pub retry_max_backoff_secs: u64,
23    pub shell_timeout_secs: u64,
24}
25
26impl Config {
27    /// Load from environment. The API key is optional here so commands that
28    /// don't need the LLM (e.g. `tools`, future offline ones) still run.
29    pub fn from_env() -> Result<Self> {
30        let workspace = std::env::var("RECURSIVE_WORKSPACE")
31            .map(PathBuf::from)
32            .unwrap_or_else(|_| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
33
34        let api_base = std::env::var("RECURSIVE_API_BASE")
35            .or_else(|_| std::env::var("OPENAI_API_BASE"))
36            .unwrap_or_else(|_| "https://api.openai.com/v1".into());
37
38        let api_key = std::env::var("RECURSIVE_API_KEY")
39            .or_else(|_| std::env::var("OPENAI_API_KEY"))
40            .ok();
41
42        let model = std::env::var("RECURSIVE_MODEL")
43            .or_else(|_| std::env::var("OPENAI_MODEL"))
44            .unwrap_or_else(|_| "gpt-4o-mini".into());
45
46        let max_steps = std::env::var("RECURSIVE_MAX_STEPS")
47            .ok()
48            .and_then(|s| s.parse().ok())
49            .unwrap_or(32);
50
51        let temperature = std::env::var("RECURSIVE_TEMPERATURE")
52            .ok()
53            .and_then(|s| s.parse().ok())
54            .unwrap_or(0.2);
55
56        let system_prompt = match std::env::var("RECURSIVE_SYSTEM_PROMPT_FILE") {
57            Ok(path) => std::fs::read_to_string(&path).map_err(|e| Error::Config {
58                message: format!("read system prompt {path}: {e}"),
59            })?,
60            Err(_) => default_system_prompt(),
61        };
62
63        let retry_max = std::env::var("RECURSIVE_RETRY_MAX")
64            .ok()
65            .and_then(|s| s.parse().ok())
66            .unwrap_or(2);
67        let retry_initial_backoff_secs = std::env::var("RECURSIVE_RETRY_INITIAL_BACKOFF_SECS")
68            .ok()
69            .and_then(|s| s.parse().ok())
70            .unwrap_or(1);
71        let retry_max_backoff_secs = std::env::var("RECURSIVE_RETRY_MAX_BACKOFF_SECS")
72            .ok()
73            .and_then(|s| s.parse().ok())
74            .unwrap_or(8);
75        let shell_timeout_secs = std::env::var("RECURSIVE_SHELL_TIMEOUT_SECS")
76            .ok()
77            .and_then(|s| s.parse().ok())
78            .unwrap_or(300);
79
80        Ok(Self {
81            workspace,
82            api_base,
83            api_key,
84            model,
85            max_steps,
86            temperature,
87            system_prompt,
88            retry_max,
89            retry_initial_backoff_secs,
90            retry_max_backoff_secs,
91            shell_timeout_secs,
92        })
93    }
94
95    /// Return the API key or a descriptive error if none was configured.
96    pub fn require_api_key(&self) -> Result<&str> {
97        self.api_key.as_deref().ok_or_else(|| Error::Config {
98            message: "set RECURSIVE_API_KEY (or OPENAI_API_KEY)".into(),
99        })
100    }
101}
102
103pub fn default_system_prompt() -> String {
104    [
105        "You are Recursive, a minimal but capable coding agent.",
106        "",
107        "Tools available: read_file, write_file, list_dir, run_shell, apply_patch, search_files.",
108        "Additional tools: estimate_tokens (estimate token count for text or file).",
109        "All file paths are workspace-relative; the sandbox will reject anything outside.",
110        "",
111        "Working principles:",
112        "- Read before you write. Skim relevant files (read_file, list_dir) before editing.",
113        "- Prefer apply_patch over write_file when modifying existing files. Use write_file only for new files or full rewrites.",
114        "- After any non-trivial code change, run the project's tests via run_shell and quote the result.",
115        "- If a tool call fails the same way twice, change approach instead of retrying.",
116        "- Stop calling tools and write a short final summary once the task is done.",
117        "",
118        "Patching with apply_patch:",
119        "- Use the V4A format (see AGENTS.md section 5 for the canonical reference).",
120        "- Each `*** Update File:` block must appear at most once per patch.",
121        "- The `@@ <anchor>` line cites an existing line; lines with leading space are unchanged context.",
122        "- Example (editing src/example.rs to add a new function):",
123        "```",
124        "*** Begin Patch",
125        "*** Update File: src/example.rs",
126        "@@ fn existing_function() {",
127        " fn existing_function();",
128        "",
129        "+fn new_function();",
130        "+",
131        " fn another_function();",
132        "*** End Patch",
133        "```",
134        "",
135        "Don't:",
136        "- Do not run `git checkout`, `git reset`, `git restore`, or any command that mutates the working tree. The orchestrator owns rollback.",
137        "- Do not edit source files via `sed -i`, `tail > file`, or `cat <<EOF`. Use apply_patch or write_file (whole file).",
138        "- Verify behavior via `cargo test`, never via `cargo run | jq`. Cargo build noise on a fresh tree breaks jq parsing and burns your step budget.",
139        "",
140        "Output should be terse and concrete. Avoid filler.",
141    ]
142    .join("\n")
143}
144
145/// Maximum size for project context file (AGENTS.md) in bytes.
146/// 16 KB is enough for a detailed project context without blowing
147/// the context window.
148const MAX_PROJECT_CONTEXT_SIZE: usize = 16 * 1024;
149
150/// Load project context from AGENTS.md at workspace root.
151///
152/// Returns the file content if present, truncated to 16 KB with a
153/// marker if larger. Returns None if absent.
154pub fn load_project_context(workspace: &Path) -> Option<String> {
155    let path = workspace.join("AGENTS.md");
156    if !path.exists() {
157        return None;
158    }
159
160    let metadata = std::fs::metadata(&path).ok()?;
161    let file_size = metadata.len() as usize;
162
163    if file_size <= MAX_PROJECT_CONTEXT_SIZE {
164        let content = std::fs::read_to_string(&path).ok()?;
165        Some(content)
166    } else {
167        // File is too large: read first 16 KB and append truncation marker
168        let mut file = std::fs::File::open(&path).ok()?;
169        use std::io::Read;
170        let mut buffer = vec![0u8; MAX_PROJECT_CONTEXT_SIZE];
171        let bytes_read = file.read(&mut buffer).ok()?;
172        buffer.truncate(bytes_read);
173        let content = String::from_utf8_lossy(&buffer).to_string();
174        let truncated_msg = format!(
175            "\n\n[…truncated, AGENTS.md is {} KB; consider trimming for fresh agent sessions]",
176            file_size / 1024
177        );
178        Some(content + &truncated_msg)
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn default_prompt_is_well_under_a_kilobyte() {
188        assert!(default_system_prompt().len() < 2048);
189    }
190
191    #[test]
192    fn default_prompt_mentions_apply_patch() {
193        assert!(default_system_prompt().contains("apply_patch"));
194    }
195
196    #[test]
197    fn default_prompt_mentions_run_shell_tests() {
198        let prompt = default_system_prompt();
199        assert!(prompt.contains("run_shell"));
200        assert!(prompt.contains("tests"));
201    }
202
203    #[test]
204    fn default_prompt_includes_new_sections() {
205        let prompt = default_system_prompt();
206        assert!(prompt.contains("apply_patch"));
207        assert!(prompt.contains("git checkout"));
208        assert!(prompt.contains("cargo test"));
209        assert!(prompt.contains("*** Begin Patch"));
210    }
211
212    #[test]
213    fn retry_defaults_match_old_policy() {
214        // Ensure defaults match the hardcoded RetryPolicy::default()
215        let config = Config {
216            workspace: PathBuf::from("."),
217            api_base: String::new(),
218            api_key: None,
219            model: String::new(),
220            max_steps: 32,
221            temperature: 0.2,
222            system_prompt: String::new(),
223            retry_max: 2,
224            retry_initial_backoff_secs: 1,
225            retry_max_backoff_secs: 8,
226            shell_timeout_secs: 300,
227        };
228        assert_eq!(config.retry_max, 2);
229        assert_eq!(config.retry_initial_backoff_secs, 1);
230        assert_eq!(config.retry_max_backoff_secs, 8);
231        assert_eq!(config.shell_timeout_secs, 300);
232    }
233
234    #[test]
235    fn retry_env_overrides_apply() {
236        // Save original env values
237        let original_max = std::env::var("RECURSIVE_RETRY_MAX");
238        let original_initial = std::env::var("RECURSIVE_RETRY_INITIAL_BACKOFF_SECS");
239        let original_max_backoff = std::env::var("RECURSIVE_RETRY_MAX_BACKOFF_SECS");
240
241        // Set custom values
242        std::env::set_var("RECURSIVE_RETRY_MAX", "5");
243        std::env::set_var("RECURSIVE_RETRY_INITIAL_BACKOFF_SECS", "2");
244        std::env::set_var("RECURSIVE_RETRY_MAX_BACKOFF_SECS", "30");
245
246        // We need to also set required env vars to avoid errors
247        std::env::set_var("RECURSIVE_MODEL", "test-model");
248        std::env::set_var("RECURSIVE_API_KEY", "test-key");
249
250        let config = Config::from_env().unwrap();
251
252        assert_eq!(config.retry_max, 5);
253        assert_eq!(config.retry_initial_backoff_secs, 2);
254        assert_eq!(config.retry_max_backoff_secs, 30);
255
256        // Restore original env values
257        std::env::remove_var("RECURSIVE_RETRY_MAX");
258        std::env::remove_var("RECURSIVE_RETRY_INITIAL_BACKOFF_SECS");
259        std::env::remove_var("RECURSIVE_RETRY_MAX_BACKOFF_SECS");
260        std::env::remove_var("RECURSIVE_MODEL");
261        std::env::remove_var("RECURSIVE_API_KEY");
262
263        if let Ok(v) = original_max {
264            std::env::set_var("RECURSIVE_RETRY_MAX", v);
265        }
266        if let Ok(v) = original_initial {
267            std::env::set_var("RECURSIVE_RETRY_INITIAL_BACKOFF_SECS", v);
268        }
269        if let Ok(v) = original_max_backoff {
270            std::env::set_var("RECURSIVE_RETRY_MAX_BACKOFF_SECS", v);
271        }
272    }
273
274    // NOTE: both shell_timeout_* checks live in ONE test on purpose.
275    // `cargo test` runs tests in parallel threads and `set_var` /
276    // `remove_var` are process-global, so splitting them creates a
277    // race (one test sees the other's value). MiniMax's goal-23 run
278    // burned 50 steps discovering this exact race; lesson recorded in
279    // AGENTS.md section 5.
280    #[test]
281    fn shell_timeout_default_and_env_override() {
282        let original = std::env::var("RECURSIVE_SHELL_TIMEOUT_SECS").ok();
283        std::env::set_var("RECURSIVE_MODEL", "test-model");
284        std::env::set_var("RECURSIVE_API_KEY", "test-key");
285
286        std::env::remove_var("RECURSIVE_SHELL_TIMEOUT_SECS");
287        let config = Config::from_env().unwrap();
288        assert_eq!(config.shell_timeout_secs, 300);
289
290        std::env::set_var("RECURSIVE_SHELL_TIMEOUT_SECS", "42");
291        let config = Config::from_env().unwrap();
292        assert_eq!(config.shell_timeout_secs, 42);
293
294        if let Some(v) = original {
295            std::env::set_var("RECURSIVE_SHELL_TIMEOUT_SECS", v);
296        } else {
297            std::env::remove_var("RECURSIVE_SHELL_TIMEOUT_SECS");
298        }
299    }
300
301    // Tests for load_project_context
302    #[test]
303    fn test_a_load_project_context_with_small_file() {
304        let tmp = tempfile::tempdir().expect("tempdir");
305        let path = tmp.path().join("AGENTS.md");
306        std::fs::write(&path, "# Project Context\n\nHello world").expect("write");
307
308        let content = load_project_context(tmp.path());
309        assert!(content.is_some());
310        assert!(content.unwrap().contains("Hello world"));
311    }
312
313    #[test]
314    fn test_b_load_project_context_truncates_large_file() {
315        let tmp = tempfile::tempdir().expect("tempdir");
316        let path = tmp.path().join("AGENTS.md");
317        // Write 20 KB of content
318        let large_content = "x".repeat(20 * 1024);
319        std::fs::write(&path, large_content).expect("write");
320
321        let content = load_project_context(tmp.path());
322        assert!(content.is_some());
323        let c = content.unwrap();
324        // Should contain truncation marker
325        assert!(c.contains("truncated"));
326        assert!(c.contains("20 KB"));
327    }
328
329    #[test]
330    fn test_c_load_project_context_none_when_missing() {
331        let tmp = tempfile::tempdir().expect("tempdir");
332        // No AGENTS.md file
333        let content = load_project_context(tmp.path());
334        assert!(content.is_none());
335    }
336}