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