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 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 pub fn from_env() -> Result<Self> {
33 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 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 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
206const MAX_PROJECT_CONTEXT_SIZE: usize = 16 * 1024;
210
211pub 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 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 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 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 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 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 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 #[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 #[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 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 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 let content = load_project_context(tmp.path());
396 assert!(content.is_none());
397 }
398}