Skip to main content

pawan/config/
mod.rs

1//! Configuration for Pawan
2//!
3//! Pawan can be configured via:
4//! - `pawan.toml` in the current directory
5//! - `[pawan]` section in `ares.toml`
6//! - Environment variables
7//! - Command line arguments
8
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::path::PathBuf;
12use tracing;
13
14/// LLM Provider type
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
16#[serde(rename_all = "lowercase")]
17pub enum LlmProvider {
18    /// NVIDIA API (build.nvidia.com) - default
19    #[default]
20    Nvidia,
21    /// Local Ollama instance
22    Ollama,
23    /// OpenAI-compatible API
24    OpenAI,
25    /// MLX LM server (Apple Silicon native, mlx_lm.server) — auto-routes to localhost:8080
26    Mlx,
27}
28
29/// Main configuration for Pawan
30#[derive(Debug, Clone, Serialize, Deserialize)]
31#[serde(default)]
32pub struct PawanConfig {
33    /// LLM provider to use
34    pub provider: LlmProvider,
35
36    /// LLM model to use for coding tasks
37    pub model: String,
38
39    /// Override the API base URL (e.g. "http://localhost:8080/v1" for llama.cpp).
40    /// Takes priority over OPENAI_API_URL / NVIDIA_API_URL env vars.
41    pub base_url: Option<String>,
42
43    /// Enable dry-run mode (show changes without applying)
44    pub dry_run: bool,
45
46    /// Create backups before editing files
47    pub auto_backup: bool,
48
49    /// Require clean git working directory
50    pub require_git_clean: bool,
51
52    /// Timeout for bash commands (seconds)
53    pub bash_timeout_secs: u64,
54
55    /// Maximum file size to read (KB)
56    pub max_file_size_kb: usize,
57
58    /// Maximum tool iterations per request
59    pub max_tool_iterations: usize,
60    /// Maximum context tokens before pruning
61    pub max_context_tokens: usize,
62
63    /// System prompt override
64    pub system_prompt: Option<String>,
65
66    /// Temperature for LLM responses
67    pub temperature: f32,
68
69    /// Top-p sampling parameter
70    pub top_p: f32,
71
72    /// Maximum tokens in response
73    pub max_tokens: usize,
74
75    /// Maximum tokens allowed for reasoning/thinking (0 = unlimited).
76    /// When set, pawan tracks thinking vs action token usage per call.
77    /// If thinking exceeds this budget, a warning is logged.
78    pub thinking_budget: usize,
79
80    /// Maximum retries for LLM API calls (429 or 5xx errors)
81    pub max_retries: usize,
82
83    /// Fallback models to try when primary model fails
84    pub fallback_models: Vec<String>,
85    /// Maximum characters in tool result before truncation
86    pub max_result_chars: usize,
87
88    /// Enable reasoning/thinking mode (for DeepSeek/Nemotron models)
89    pub reasoning_mode: bool,
90
91    /// Healing configuration
92    pub healing: HealingConfig,
93
94    /// Target projects
95    pub targets: HashMap<String, TargetConfig>,
96
97    /// TUI configuration
98    pub tui: TuiConfig,
99
100    /// MCP server configurations
101    #[serde(default)]
102    pub mcp: HashMap<String, McpServerEntry>,
103
104    /// Tool permission overrides (tool_name -> permission)
105    #[serde(default)]
106    pub permissions: HashMap<String, ToolPermission>,
107
108    /// Cloud fallback: when primary model fails, fall back to cloud provider.
109    /// Enables hybrid local+cloud routing.
110    pub cloud: Option<CloudConfig>,
111
112    /// Task-type model routing: use different models for different task categories.
113    /// If not set, all tasks use the primary model.
114    #[serde(default)]
115    pub models: ModelRouting,
116
117    /// Eruka context engine integration (3-tier memory injection)
118    #[serde(default)]
119    pub eruka: crate::eruka_bridge::ErukaConfig,
120}
121
122/// Task-type model routing — use different models for different task categories.
123///
124/// # Example (pawan.toml)
125/// ```toml
126/// [models]
127/// code = "mistralai/mistral-small-4-119b-2603"     # best for code generation
128/// orchestrate = "stepfun-ai/step-3.5-flash"         # best for tool calling
129/// execute = "mlx-community/Qwen3.5-9B-OptiQ-4bit"  # fast local execution
130/// ```
131#[derive(Debug, Clone, Default, Serialize, Deserialize)]
132pub struct ModelRouting {
133    /// Model for code generation tasks (implement, refactor, write tests)
134    pub code: Option<String>,
135    /// Model for orchestration tasks (multi-step tool chains, analysis)
136    pub orchestrate: Option<String>,
137    /// Model for simple execution tasks (bash, write_file, cargo test)
138    pub execute: Option<String>,
139}
140
141impl ModelRouting {
142    /// Select the best model for a given task based on keyword analysis.
143    /// Returns None if no routing matches (use default model).
144    pub fn route(&self, query: &str) -> Option<&str> {
145        let q = query.to_lowercase();
146
147        // Code generation patterns
148        if self.code.is_some() {
149            let code_signals = ["implement", "write", "create", "refactor", "fix", "add test",
150                "add function", "struct", "enum", "trait", "algorithm", "data structure"];
151            if code_signals.iter().any(|s| q.contains(s)) {
152                return self.code.as_deref();
153            }
154        }
155
156        // Orchestration patterns
157        if self.orchestrate.is_some() {
158            let orch_signals = ["search", "find", "analyze", "review", "explain", "compare",
159                "list", "check", "verify", "diagnose", "audit"];
160            if orch_signals.iter().any(|s| q.contains(s)) {
161                return self.orchestrate.as_deref();
162            }
163        }
164
165        // Execution patterns
166        if self.execute.is_some() {
167            let exec_signals = ["run", "execute", "bash", "cargo", "test", "build",
168                "deploy", "install", "commit"];
169            if exec_signals.iter().any(|s| q.contains(s)) {
170                return self.execute.as_deref();
171            }
172        }
173
174        None
175    }
176}
177
178/// Cloud fallback configuration for hybrid local+cloud model routing.
179///
180/// When the primary provider (typically a local model via OpenAI-compatible API)
181/// fails or is unavailable, pawan automatically falls back to this cloud provider.
182/// This enables zero-cost local inference with cloud reliability as a safety net.
183///
184/// # Example (pawan.toml)
185/// ```toml
186/// provider = "openai"
187/// model = "Qwen3.5-9B-Q4_K_M"
188///
189/// [cloud]
190/// provider = "nvidia"
191/// model = "mistralai/devstral-2-123b-instruct-2512"
192/// ```
193#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct CloudConfig {
195    /// Cloud LLM provider to fall back to (nvidia or openai)
196    pub provider: LlmProvider,
197    /// Primary cloud model to try first on fallback
198    pub model: String,
199    /// Additional cloud models to try if the primary cloud model also fails
200    #[serde(default)]
201    pub fallback_models: Vec<String>,
202}
203
204/// Permission level for a tool
205#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
206#[serde(rename_all = "lowercase")]
207pub enum ToolPermission {
208    /// Always allow (default for most tools)
209    Allow,
210    /// Deny — tool is disabled
211    Deny,
212}
213
214impl Default for PawanConfig {
215    fn default() -> Self {
216        let mut targets = HashMap::new();
217        targets.insert(
218            "ares".to_string(),
219            TargetConfig {
220                path: PathBuf::from("../.."),
221                description: "A.R.E.S server codebase".to_string(),
222            },
223        );
224        targets.insert(
225            "self".to_string(),
226            TargetConfig {
227                path: PathBuf::from("."),
228                description: "Pawan's own codebase".to_string(),
229            },
230        );
231
232        Self {
233            provider: LlmProvider::Nvidia,
234            model: crate::DEFAULT_MODEL.to_string(),
235            base_url: None,
236            dry_run: false,
237            auto_backup: true,
238            require_git_clean: false,
239            bash_timeout_secs: crate::DEFAULT_BASH_TIMEOUT,
240            max_file_size_kb: 1024,
241            max_tool_iterations: crate::MAX_TOOL_ITERATIONS,
242            max_context_tokens: 100000,
243            system_prompt: None,
244            temperature: 1.0,
245            top_p: 0.95,
246            max_tokens: 8192,
247            thinking_budget: 0, // 0 = unlimited
248            reasoning_mode: true,
249            max_retries: 3,
250            fallback_models: Vec::new(),
251            max_result_chars: 8000,
252            healing: HealingConfig::default(),
253            targets,
254            tui: TuiConfig::default(),
255            mcp: HashMap::new(),
256            permissions: HashMap::new(),
257            cloud: None,
258            models: ModelRouting::default(),
259            eruka: crate::eruka_bridge::ErukaConfig::default(),
260        }
261    }
262}
263
264/// Configuration for self-healing behavior
265#[derive(Debug, Clone, Serialize, Deserialize)]
266#[serde(default)]
267pub struct HealingConfig {
268    /// Automatically commit fixes
269    pub auto_commit: bool,
270
271    /// Fix compilation errors
272    pub fix_errors: bool,
273
274    /// Fix clippy warnings
275    pub fix_warnings: bool,
276
277    /// Fix failing tests
278    pub fix_tests: bool,
279
280    /// Generate missing documentation
281    pub generate_docs: bool,
282
283    /// Maximum fix attempts per issue
284    pub max_attempts: usize,
285}
286
287impl Default for HealingConfig {
288    fn default() -> Self {
289        Self {
290            auto_commit: false,
291            fix_errors: true,
292            fix_warnings: true,
293            fix_tests: true,
294            generate_docs: false,
295            max_attempts: 3,
296        }
297    }
298}
299
300/// Configuration for a target project
301#[derive(Debug, Clone, Serialize, Deserialize)]
302/// Configuration for a target project
303///
304/// This struct represents configuration for a specific target project that Pawan
305/// can work with. It includes the project path and description.
306pub struct TargetConfig {
307    /// Path to the project root
308    pub path: PathBuf,
309
310    /// Description of the project
311    pub description: String,
312}
313
314/// Configuration for the TUI
315#[derive(Debug, Clone, Serialize, Deserialize)]
316#[serde(default)]
317pub struct TuiConfig {
318    /// Enable syntax highlighting
319    pub syntax_highlighting: bool,
320
321    /// Theme for syntax highlighting
322    pub theme: String,
323
324    /// Show line numbers in code blocks
325    pub line_numbers: bool,
326
327    /// Enable mouse support
328    pub mouse_support: bool,
329
330    /// Scroll speed (lines per scroll event)
331    pub scroll_speed: usize,
332
333    /// Maximum history entries to keep
334    pub max_history: usize,
335}
336
337impl Default for TuiConfig {
338    fn default() -> Self {
339        Self {
340            syntax_highlighting: true,
341            theme: "base16-ocean.dark".to_string(),
342            line_numbers: true,
343            mouse_support: true,
344            scroll_speed: 3,
345            max_history: 1000,
346        }
347    }
348}
349
350/// Configuration for an MCP server in pawan.toml
351#[derive(Debug, Clone, Serialize, Deserialize)]
352/// Configuration for an MCP server in pawan.toml
353///
354/// This struct represents configuration for an MCP (Multi-Cursor Protocol) server
355/// that can be managed by Pawan. It includes the command to run, arguments,
356/// environment variables, and whether the server is enabled.
357pub struct McpServerEntry {
358    /// Command to run
359    pub command: String,
360    /// Command arguments
361    #[serde(default)]
362    pub args: Vec<String>,
363    /// Environment variables
364    #[serde(default)]
365    pub env: HashMap<String, String>,
366    /// Whether this server is enabled
367    #[serde(default = "default_true")]
368    pub enabled: bool,
369}
370
371fn default_true() -> bool {
372    true
373}
374
375impl PawanConfig {
376    /// Load configuration from file
377    pub fn load(path: Option<&PathBuf>) -> crate::Result<Self> {
378        let config_path = path.cloned().or_else(|| {
379            // 1. pawan.toml in CWD
380            let pawan_toml = PathBuf::from("pawan.toml");
381            if pawan_toml.exists() {
382                return Some(pawan_toml);
383            }
384
385            // 2. ares.toml in CWD
386            let ares_toml = PathBuf::from("ares.toml");
387            if ares_toml.exists() {
388                return Some(ares_toml);
389            }
390
391            // 3. Global user config: ~/.config/pawan/pawan.toml
392            if let Some(home) = dirs::home_dir() {
393                let global = home.join(".config/pawan/pawan.toml");
394                if global.exists() {
395                    return Some(global);
396                }
397            }
398
399            None
400        });
401
402        match config_path {
403            Some(path) => {
404                let content = std::fs::read_to_string(&path).map_err(|e| {
405                    crate::PawanError::Config(format!("Failed to read {}: {}", path.display(), e))
406                })?;
407
408                // Check if this is ares.toml (look for [pawan] section)
409                if path.file_name().map(|n| n == "ares.toml").unwrap_or(false) {
410                    // Parse as TOML and extract [pawan] section
411                    let value: toml::Value = toml::from_str(&content).map_err(|e| {
412                        crate::PawanError::Config(format!(
413                            "Failed to parse {}: {}",
414                            path.display(),
415                            e
416                        ))
417                    })?;
418
419                    if let Some(pawan_section) = value.get("pawan") {
420                        let config: PawanConfig =
421                            pawan_section.clone().try_into().map_err(|e| {
422                                crate::PawanError::Config(format!(
423                                    "Failed to parse [pawan] section: {}",
424                                    e
425                                ))
426                            })?;
427                        return Ok(config);
428                    }
429
430                    // No [pawan] section, use defaults
431                    Ok(Self::default())
432                } else {
433                    // Parse as pawan.toml
434                    toml::from_str(&content).map_err(|e| {
435                        crate::PawanError::Config(format!(
436                            "Failed to parse {}: {}",
437                            path.display(),
438                            e
439                        ))
440                    })
441                }
442            }
443            None => Ok(Self::default()),
444        }
445    }
446
447    /// Apply environment variable overrides (PAWAN_MODEL, PAWAN_PROVIDER, etc.)
448    pub fn apply_env_overrides(&mut self) {
449        if let Ok(model) = std::env::var("PAWAN_MODEL") {
450            self.model = model;
451        }
452        if let Ok(provider) = std::env::var("PAWAN_PROVIDER") {
453            match provider.to_lowercase().as_str() {
454                "nvidia" | "nim" => self.provider = LlmProvider::Nvidia,
455                "ollama" => self.provider = LlmProvider::Ollama,
456                "openai" => self.provider = LlmProvider::OpenAI,
457                "mlx" | "mlx-lm" => self.provider = LlmProvider::Mlx,
458                _ => tracing::warn!(provider = provider.as_str(), "Unknown PAWAN_PROVIDER, ignoring"),
459            }
460        }
461        if let Ok(temp) = std::env::var("PAWAN_TEMPERATURE") {
462            if let Ok(t) = temp.parse::<f32>() {
463                self.temperature = t;
464            }
465        }
466        if let Ok(tokens) = std::env::var("PAWAN_MAX_TOKENS") {
467            if let Ok(t) = tokens.parse::<usize>() {
468                self.max_tokens = t;
469            }
470        }
471        if let Ok(iters) = std::env::var("PAWAN_MAX_ITERATIONS") {
472            if let Ok(i) = iters.parse::<usize>() {
473                self.max_tool_iterations = i;
474            }
475        }
476        if let Ok(ctx) = std::env::var("PAWAN_MAX_CONTEXT_TOKENS") {
477            if let Ok(c) = ctx.parse::<usize>() {
478                self.max_context_tokens = c;
479            }
480        }
481        if let Ok(models) = std::env::var("PAWAN_FALLBACK_MODELS") {
482            self.fallback_models = models.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect();
483        }
484        if let Ok(chars) = std::env::var("PAWAN_MAX_RESULT_CHARS") {
485            if let Ok(c) = chars.parse::<usize>() {
486                self.max_result_chars = c;
487            }
488        }
489    }
490
491    /// Get target by name
492    pub fn get_target(&self, name: &str) -> Option<&TargetConfig> {
493        self.targets.get(name)
494    }
495
496    /// Get the system prompt, with optional PAWAN.md context injection
497    pub fn get_system_prompt(&self) -> String {
498        let base = self
499            .system_prompt
500            .clone()
501            .unwrap_or_else(|| DEFAULT_SYSTEM_PROMPT.to_string());
502
503        // Try to load PAWAN.md from current directory for project context
504        let context = Self::load_context_file();
505        if let Some(ctx) = context {
506            format!("{}\n\n## Project Context (from PAWAN.md)\n\n{}", base, ctx)
507        } else {
508            base
509        }
510    }
511
512    /// Load PAWAN.md context file from current directory (if it exists)
513    fn load_context_file() -> Option<String> {
514        // Check PAWAN.md first, then .pawan/context.md
515        for path in &["PAWAN.md", ".pawan/context.md"] {
516            let p = PathBuf::from(path);
517            if p.exists() {
518                if let Ok(content) = std::fs::read_to_string(&p) {
519                    if !content.trim().is_empty() {
520                        return Some(content);
521                    }
522                }
523            }
524        }
525        None
526    }
527
528    /// Check if thinking mode should be enabled.
529    /// Only applicable to DeepSeek models (other NIM models don't support thinking tokens).
530    pub fn use_thinking_mode(&self) -> bool {
531        self.reasoning_mode && self.model.contains("deepseek")
532    }
533}
534
535/// Default system prompt for coding tasks
536pub const DEFAULT_SYSTEM_PROMPT: &str = r#"You are Pawan, an expert coding assistant.
537
538CRITICAL — Efficiency rules (you have limited tool iterations):
539- Act immediately. Do NOT explore, plan, or check existence before writing.
540- write_file creates parents automatically. No need to mkdir first.
541- Write code FIRST, then verify. cargo check runs automatically after .rs writes.
542- Use relative paths from workspace root.
543- If a tool is missing, it will be auto-installed. Don't worry about dependencies.
544
545Tool priorities (use the best tool for the job):
546- Web search: use mcp_daedra_web_search for ANY web/internet query. It searches Wikipedia,
547  StackOverflow, GitHub, and more. NEVER use bash+curl for web search — use this tool.
548- Code edits: prefer ast_grep rewrite for structural changes (rename, refactor, pattern replace).
549  Only use edit_file/edit_file_lines for non-code files or when ast_grep can't express the change.
550- Code search: prefer ast_grep search for structural queries (find all functions, find unwrap() calls).
551  Use grep_search for text/regex patterns. Use glob_search for file discovery.
552- Project overview: use tree with disk_usage="line" for lines-of-code per directory.
553- Tool/runtime management: use mise to install, manage, or run any tool or language.
554
555Available tools:
556- File: read_file, write_file, edit_file, edit_file_lines, insert_after, append_file, list_directory
557- Code: ast_grep (AST search + rewrite)
558- Search: glob_search, grep_search, ripgrep, fd
559- Shell: bash, sd (find-replace), tree (erdtree), mise (tool/task/env manager), zoxide
560- Git: git_status, git_diff, git_add, git_commit, git_log, git_blame, git_branch, git_checkout, git_stash
561- Agent: spawn_agent, spawn_agents
562
563Rules:
5641. Make minimal, focused changes. Follow existing code style.
5652. After .rs writes, cargo check auto-runs — fix errors immediately if it fails.
5663. Run tests when the task calls for it (cargo test -p <crate>).
5674. One fix at a time. If it doesn't work, try a different approach.
568
569Be concise. Act first, explain briefly after.
570
571Git commits: always use author `bkataru <baalateja.k@gmail.com>` via -c flags."#;
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576
577    #[test]
578    fn test_provider_mlx_parsing() {
579        // "mlx" string parses to LlmProvider::Mlx via serde rename_all = "lowercase"
580        let toml = r#"
581provider = "mlx"
582model = "mlx-community/Qwen3.5-9B-4bit"
583"#;
584        let config: PawanConfig = toml::from_str(toml).expect("should parse without error");
585        assert_eq!(config.provider, LlmProvider::Mlx);
586        assert_eq!(config.model, "mlx-community/Qwen3.5-9B-4bit");
587    }
588
589    #[test]
590    fn test_provider_mlx_lm_alias() {
591        // "mlx-lm" is an alias for mlx via apply_env_overrides (env var path)
592        let mut config = PawanConfig::default();
593        std::env::set_var("PAWAN_PROVIDER", "mlx-lm");
594        config.apply_env_overrides();
595        std::env::remove_var("PAWAN_PROVIDER");
596        assert_eq!(config.provider, LlmProvider::Mlx);
597    }
598
599    #[test]
600    fn test_mlx_base_url_override() {
601        // When provider=mlx and base_url is set, base_url is preserved in config
602        let toml = r#"
603provider = "mlx"
604model = "test-model"
605base_url = "http://192.168.1.100:8080/v1"
606"#;
607        let config: PawanConfig = toml::from_str(toml).expect("should parse without error");
608        assert_eq!(config.provider, LlmProvider::Mlx);
609        assert_eq!(
610            config.base_url.as_deref(),
611            Some("http://192.168.1.100:8080/v1")
612        );
613    }
614}