1use 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 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 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
145const MAX_PROJECT_CONTEXT_SIZE: usize = 16 * 1024;
149
150pub 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 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 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 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 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 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 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 #[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 #[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 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 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 let content = load_project_context(tmp.path());
334 assert!(content.is_none());
335 }
336}