use std::path::{Path, PathBuf};
use crate::error::{Error, Result};
#[derive(Debug, Clone)]
pub struct Config {
pub workspace: PathBuf,
pub api_base: String,
pub api_key: Option<String>,
pub model: String,
pub provider_type: String,
pub max_steps: usize,
pub temperature: f64,
pub system_prompt: String,
pub retry_max: usize,
pub retry_initial_backoff_secs: u64,
pub retry_max_backoff_secs: u64,
pub shell_timeout_secs: u64,
}
impl Config {
pub fn from_env() -> Result<Self> {
let file_config = crate::config_file::FileConfig::load()
.map_err(|e| Error::Config {
message: format!("config file: {e}"),
})?
.unwrap_or_default();
let file_provider = file_config.provider.as_ref();
let file_agent = file_config.agent.as_ref();
let workspace = std::env::var("RECURSIVE_WORKSPACE")
.map(PathBuf::from)
.unwrap_or_else(|_| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
let api_base = std::env::var("RECURSIVE_API_BASE")
.or_else(|_| std::env::var("OPENAI_API_BASE"))
.ok()
.or_else(|| file_provider.and_then(|p| p.api_base.clone()))
.unwrap_or_else(|| "https://api.openai.com/v1".into());
let api_key = std::env::var("RECURSIVE_API_KEY")
.or_else(|_| std::env::var("OPENAI_API_KEY"))
.ok()
.or_else(|| file_provider.and_then(|p| p.api_key.clone()));
let model = std::env::var("RECURSIVE_MODEL")
.or_else(|_| std::env::var("OPENAI_MODEL"))
.ok()
.or_else(|| file_provider.and_then(|p| p.model.clone()))
.unwrap_or_else(|| "gpt-4o-mini".into());
let max_steps = std::env::var("RECURSIVE_MAX_STEPS")
.ok()
.and_then(|s| s.parse().ok())
.or_else(|| file_agent.and_then(|a| a.max_steps))
.unwrap_or(32);
let temperature = std::env::var("RECURSIVE_TEMPERATURE")
.ok()
.and_then(|s| s.parse().ok())
.or_else(|| file_agent.and_then(|a| a.temperature))
.unwrap_or(0.2);
let system_prompt = match std::env::var("RECURSIVE_SYSTEM_PROMPT_FILE") {
Ok(path) => std::fs::read_to_string(&path).map_err(|e| Error::Config {
message: format!("read system prompt {path}: {e}"),
})?,
Err(_) => default_system_prompt(),
};
let retry_max = std::env::var("RECURSIVE_RETRY_MAX")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(2);
let retry_initial_backoff_secs = std::env::var("RECURSIVE_RETRY_INITIAL_BACKOFF_SECS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(1);
let retry_max_backoff_secs = std::env::var("RECURSIVE_RETRY_MAX_BACKOFF_SECS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(8);
let shell_timeout_secs = std::env::var("RECURSIVE_SHELL_TIMEOUT_SECS")
.ok()
.and_then(|s| s.parse().ok())
.or_else(|| file_agent.and_then(|a| a.shell_timeout_secs))
.unwrap_or(300);
let provider_type = std::env::var("RECURSIVE_PROVIDER_TYPE")
.ok()
.or_else(|| file_provider.and_then(|p| p.provider_type.clone()))
.unwrap_or_else(|| "openai".into());
Ok(Self {
workspace,
api_base,
api_key,
model,
provider_type,
max_steps,
temperature,
system_prompt,
retry_max,
retry_initial_backoff_secs,
retry_max_backoff_secs,
shell_timeout_secs,
})
}
pub fn require_api_key(&self) -> Result<&str> {
self.api_key.as_deref().ok_or_else(|| Error::Config {
message: "set RECURSIVE_API_KEY (or OPENAI_API_KEY)".into(),
})
}
pub fn validate_for_agent(&self) -> std::result::Result<(), String> {
if self.api_key.is_none() || self.api_key.as_deref() == Some("") {
return Err("\
Error: No API key configured.
Set one of:
--api-key <KEY>
RECURSIVE_API_KEY=<KEY>
OPENAI_API_KEY=<KEY>
Or create ~/.recursive/config.toml:
[provider]
api_key = \"your-key-here\"
Example:
recursive --api-key sk-xxx --model deepseek-chat run \"hello\"
"
.to_string());
}
if !["openai", "anthropic"].contains(&self.provider_type.as_str()) {
return Err(format!(
"\
Error: Unknown provider type '{}'.
Supported providers: openai, anthropic
Set via --provider or RECURSIVE_PROVIDER_TYPE.
",
self.provider_type
));
}
Ok(())
}
}
pub fn default_system_prompt() -> String {
[
"You are Recursive, a minimal but capable coding agent.",
"",
"Tools available: read_file, write_file, list_dir, run_shell, apply_patch, search_files.",
"Additional tools: estimate_tokens (estimate token count for text or file).",
"All file paths are workspace-relative; the sandbox will reject anything outside.",
"",
"Working principles:",
"- Read before you write. Skim relevant files (read_file, list_dir) before editing.",
"- Prefer apply_patch over write_file when modifying existing files. Use write_file only for new files or full rewrites.",
"- After any non-trivial code change, run the project's tests via run_shell and quote the result.",
"- If a tool call fails the same way twice, change approach instead of retrying.",
"- Stop calling tools and write a short final summary once the task is done.",
"",
"Patching with apply_patch:",
"- Use the V4A format (see AGENTS.md section 5 for the canonical reference).",
"- Each `*** Update File:` block must appear at most once per patch.",
"- The `@@ <anchor>` line cites an existing line; lines with leading space are unchanged context.",
"- Example (editing src/example.rs to add a new function):",
"```",
"*** Begin Patch",
"*** Update File: src/example.rs",
"@@ fn existing_function() {",
" fn existing_function();",
"",
"+fn new_function();",
"+",
" fn another_function();",
"*** End Patch",
"```",
"",
"Don't:",
"- Do not run `git checkout`, `git reset`, `git restore`, or any command that mutates the working tree. The orchestrator owns rollback.",
"- Do not edit source files via `sed -i`, `tail > file`, or `cat <<EOF`. Use apply_patch or write_file (whole file).",
"- 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.",
"",
"Output should be terse and concrete. Avoid filler.",
]
.join("\n")
}
const MAX_PROJECT_CONTEXT_SIZE: usize = 16 * 1024;
pub fn load_project_context(workspace: &Path) -> Option<String> {
let path = workspace.join("AGENTS.md");
if !path.exists() {
return None;
}
let metadata = std::fs::metadata(&path).ok()?;
let file_size = metadata.len() as usize;
if file_size <= MAX_PROJECT_CONTEXT_SIZE {
let content = std::fs::read_to_string(&path).ok()?;
Some(content)
} else {
let mut file = std::fs::File::open(&path).ok()?;
use std::io::Read;
let mut buffer = vec![0u8; MAX_PROJECT_CONTEXT_SIZE];
let bytes_read = file.read(&mut buffer).ok()?;
buffer.truncate(bytes_read);
let content = String::from_utf8_lossy(&buffer).to_string();
let truncated_msg = format!(
"\n\n[…truncated, AGENTS.md is {} KB; consider trimming for fresh agent sessions]",
file_size / 1024
);
Some(content + &truncated_msg)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_prompt_is_well_under_a_kilobyte() {
assert!(default_system_prompt().len() < 2048);
}
#[test]
fn default_prompt_mentions_apply_patch() {
assert!(default_system_prompt().contains("apply_patch"));
}
#[test]
fn default_prompt_mentions_run_shell_tests() {
let prompt = default_system_prompt();
assert!(prompt.contains("run_shell"));
assert!(prompt.contains("tests"));
}
#[test]
fn default_prompt_includes_new_sections() {
let prompt = default_system_prompt();
assert!(prompt.contains("apply_patch"));
assert!(prompt.contains("git checkout"));
assert!(prompt.contains("cargo test"));
assert!(prompt.contains("*** Begin Patch"));
}
#[test]
fn retry_defaults_match_old_policy() {
let config = Config {
workspace: PathBuf::from("."),
api_base: String::new(),
api_key: None,
model: String::new(),
provider_type: "openai".into(),
max_steps: 32,
temperature: 0.2,
system_prompt: String::new(),
retry_max: 2,
retry_initial_backoff_secs: 1,
retry_max_backoff_secs: 8,
shell_timeout_secs: 300,
};
assert_eq!(config.retry_max, 2);
assert_eq!(config.retry_initial_backoff_secs, 1);
assert_eq!(config.retry_max_backoff_secs, 8);
assert_eq!(config.shell_timeout_secs, 300);
}
#[test]
fn retry_env_overrides_apply() {
let original_max = std::env::var("RECURSIVE_RETRY_MAX");
let original_initial = std::env::var("RECURSIVE_RETRY_INITIAL_BACKOFF_SECS");
let original_max_backoff = std::env::var("RECURSIVE_RETRY_MAX_BACKOFF_SECS");
std::env::set_var("RECURSIVE_RETRY_MAX", "5");
std::env::set_var("RECURSIVE_RETRY_INITIAL_BACKOFF_SECS", "2");
std::env::set_var("RECURSIVE_RETRY_MAX_BACKOFF_SECS", "30");
std::env::set_var("RECURSIVE_MODEL", "test-model");
std::env::set_var("RECURSIVE_API_KEY", "test-key");
let config = Config::from_env().unwrap();
assert_eq!(config.retry_max, 5);
assert_eq!(config.retry_initial_backoff_secs, 2);
assert_eq!(config.retry_max_backoff_secs, 30);
std::env::remove_var("RECURSIVE_RETRY_MAX");
std::env::remove_var("RECURSIVE_RETRY_INITIAL_BACKOFF_SECS");
std::env::remove_var("RECURSIVE_RETRY_MAX_BACKOFF_SECS");
std::env::remove_var("RECURSIVE_MODEL");
std::env::remove_var("RECURSIVE_API_KEY");
if let Ok(v) = original_max {
std::env::set_var("RECURSIVE_RETRY_MAX", v);
}
if let Ok(v) = original_initial {
std::env::set_var("RECURSIVE_RETRY_INITIAL_BACKOFF_SECS", v);
}
if let Ok(v) = original_max_backoff {
std::env::set_var("RECURSIVE_RETRY_MAX_BACKOFF_SECS", v);
}
}
#[test]
fn shell_timeout_default_and_env_override() {
let original = std::env::var("RECURSIVE_SHELL_TIMEOUT_SECS").ok();
std::env::set_var("RECURSIVE_MODEL", "test-model");
std::env::set_var("RECURSIVE_API_KEY", "test-key");
std::env::remove_var("RECURSIVE_SHELL_TIMEOUT_SECS");
let config = Config::from_env().unwrap();
assert_eq!(config.shell_timeout_secs, 300);
std::env::set_var("RECURSIVE_SHELL_TIMEOUT_SECS", "42");
let config = Config::from_env().unwrap();
assert_eq!(config.shell_timeout_secs, 42);
if let Some(v) = original {
std::env::set_var("RECURSIVE_SHELL_TIMEOUT_SECS", v);
} else {
std::env::remove_var("RECURSIVE_SHELL_TIMEOUT_SECS");
}
}
#[test]
fn test_a_load_project_context_with_small_file() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("AGENTS.md");
std::fs::write(&path, "# Project Context\n\nHello world").expect("write");
let content = load_project_context(tmp.path());
assert!(content.is_some());
assert!(content.unwrap().contains("Hello world"));
}
#[test]
fn test_b_load_project_context_truncates_large_file() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("AGENTS.md");
let large_content = "x".repeat(20 * 1024);
std::fs::write(&path, large_content).expect("write");
let content = load_project_context(tmp.path());
assert!(content.is_some());
let c = content.unwrap();
assert!(c.contains("truncated"));
assert!(c.contains("20 KB"));
}
#[test]
fn test_c_load_project_context_none_when_missing() {
let tmp = tempfile::tempdir().expect("tempdir");
let content = load_project_context(tmp.path());
assert!(content.is_none());
}
}