Skip to main content

pawan/config/
pawan.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::path::PathBuf;
4use tracing;
5
6use super::defaults::{default_config_version, default_tool_idle_timeout};
7use super::healing::HealingConfig;
8use super::mcp::McpServerEntry;
9use super::migration::{migrate_to_latest, save_config};
10use super::permissions::ToolPermission;
11use super::prompt::DEFAULT_SYSTEM_PROMPT;
12use super::provider::LlmProvider;
13use super::routing::{CloudConfig, ModelRouting};
14use super::target::TargetConfig;
15use super::tui::TuiConfig;
16
17/// Main configuration for Pawan
18#[derive(Debug, Clone, Serialize, Deserialize)]
19#[serde(default)]
20pub struct PawanConfig {
21    /// Config version for migration tracking (default: 1)
22    #[serde(default = "default_config_version")]
23    pub config_version: u32,
24
25    /// LLM provider to use
26    pub provider: LlmProvider,
27
28    /// LLM model to use for coding tasks
29    pub model: String,
30
31    /// Override the API base URL (e.g. "http://localhost:8080/v1" for llama.cpp).
32    /// Takes priority over OPENAI_API_URL / NVIDIA_API_URL env vars.
33    pub base_url: Option<String>,
34
35    /// Enable dry-run mode (show changes without applying)
36    pub dry_run: bool,
37
38    /// Create backups before editing files
39    pub auto_backup: bool,
40
41    /// Require clean git working directory
42    pub require_git_clean: bool,
43
44    /// Timeout for bash commands (seconds)
45    pub bash_timeout_secs: u64,
46
47    /// Timeout for tool calls that remain idle (seconds)
48    /// Default: 300 (5 minutes)
49    #[serde(default = "default_tool_idle_timeout")]
50    pub tool_call_idle_timeout_secs: u64,
51
52    /// Maximum file size to read (KB)
53    pub max_file_size_kb: usize,
54
55    /// Maximum tool iterations per request
56    pub max_tool_iterations: usize,
57    /// Maximum context tokens before pruning
58    pub max_context_tokens: usize,
59
60    /// System prompt override
61    pub system_prompt: Option<String>,
62
63    /// Temperature for LLM responses
64    pub temperature: f32,
65
66    /// Top-p sampling parameter
67    pub top_p: f32,
68
69    /// Maximum tokens in response
70    pub max_tokens: usize,
71
72    /// Maximum tokens allowed for reasoning/thinking (0 = unlimited).
73    /// When set, pawan tracks thinking vs action token usage per call.
74    /// If thinking exceeds this budget, a warning is logged.
75    pub thinking_budget: usize,
76
77    /// Maximum retries for LLM API calls (429 or 5xx errors)
78    pub max_retries: usize,
79
80    /// Fallback models to try when primary model fails
81    pub fallback_models: Vec<String>,
82    /// Maximum characters in tool result before truncation
83    pub max_result_chars: usize,
84
85    /// Enable reasoning/thinking mode (for DeepSeek/Nemotron models)
86    pub reasoning_mode: bool,
87
88    /// Healing configuration
89    pub healing: HealingConfig,
90
91    /// Target projects
92    pub targets: HashMap<String, TargetConfig>,
93
94    /// TUI configuration
95    pub tui: TuiConfig,
96
97    /// MCP server configurations
98    #[serde(default)]
99    pub mcp: HashMap<String, McpServerEntry>,
100
101    /// Tool permission overrides (tool_name -> permission)
102    #[serde(default)]
103    pub permissions: HashMap<String, ToolPermission>,
104
105    /// Cloud fallback: when primary model fails, fall back to cloud provider.
106    /// Enables hybrid local+cloud routing.
107    pub cloud: Option<CloudConfig>,
108
109    /// Task-type model routing: use different models for different task categories.
110    /// If not set, all tasks use the primary model.
111    #[serde(default)]
112    pub models: ModelRouting,
113
114    /// Eruka context engine integration (3-tier memory injection)
115    #[serde(default)]
116    pub eruka: crate::eruka_bridge::ErukaConfig,
117
118    /// Use ares-server's LLMClient + ToolCoordinator primitives instead of
119    /// pawan's built-in OpenAI-compatible backend. Requires the `ares` feature
120    /// flag to be enabled when building pawan-core. When true, pawan delegates
121    /// LLM generation to ares which provides connection pooling, loop detection,
122    /// and unified multi-provider support. Default: false (backwards compatible).
123    #[serde(default)]
124    pub use_ares_backend: bool,
125    /// Use the ToolCoordinator for tool-calling loops instead of pawan's
126    /// built-in implementation. When true, delegates tool execution to the
127    /// coordinator which provides parallel execution, timeouts, and consistent
128    /// error handling. Default: false (backwards compatible).
129    #[serde(default)]
130    pub use_coordinator: bool,
131
132    /// Optional path to a skills repository (directory of SKILL.md files).
133    ///
134    /// Mirrors the dstack pattern: public repo + private skills linked by
135    /// config. When set, pawan discovers all SKILL.md files under this path
136    /// at runtime via thulp-skill-files SkillLoader. Useful for linking
137    /// private skill libraries without embedding them in the public repo.
138    ///
139    /// Resolution order:
140    ///   1. `PAWAN_SKILLS_REPO` environment variable
141    ///   2. `skills_repo` field in pawan.toml
142    ///   3. `~/.config/pawan/skills` if it exists
143    ///   4. None (no skill discovery beyond the project SKILL.md)
144    #[serde(default)]
145    pub skills_repo: Option<PathBuf>,
146
147    /// Prefer local inference over cloud when a local model server is reachable.
148    /// Before each session pawan probes `local_endpoint` (or the Ollama default
149    /// `http://localhost:11434/v1`) with a 100 ms TCP timeout.
150    /// If the server responds, it is used instead of the configured cloud provider.
151    /// If the server is unreachable the configured provider is used as normal.
152    /// Default: false (always use configured provider).
153    #[serde(default)]
154    pub local_first: bool,
155
156    /// Local inference endpoint URL for the `local_first` probe.
157    /// Must be an OpenAI-compatible `/v1` endpoint (Ollama, llama.cpp, LM Studio, …).
158    /// Defaults to `http://localhost:11434/v1` (Ollama) when not set.
159    #[serde(default)]
160    pub local_endpoint: Option<String>,
161}
162
163impl Default for PawanConfig {
164    fn default() -> Self {
165        let mut targets = HashMap::new();
166        targets.insert(
167            "self".to_string(),
168            TargetConfig {
169                path: PathBuf::from("."),
170                description: "Current project codebase".to_string(),
171            },
172        );
173
174        Self {
175            provider: LlmProvider::Nvidia,
176            config_version: default_config_version(),
177            model: crate::DEFAULT_MODEL.to_string(),
178            base_url: None,
179            dry_run: false,
180            auto_backup: true,
181            require_git_clean: false,
182            bash_timeout_secs: crate::DEFAULT_BASH_TIMEOUT,
183            tool_call_idle_timeout_secs: default_tool_idle_timeout(),
184            max_file_size_kb: 1024,
185            max_tool_iterations: crate::MAX_TOOL_ITERATIONS,
186            max_context_tokens: 100000,
187            system_prompt: None,
188            temperature: 1.0,
189            top_p: 0.95,
190            max_tokens: 8192,
191            thinking_budget: 0, // 0 = unlimited
192            reasoning_mode: true,
193            max_retries: 3,
194            fallback_models: Vec::new(),
195            max_result_chars: 8000,
196            healing: HealingConfig::default(),
197            targets,
198            tui: TuiConfig::default(),
199            mcp: HashMap::new(),
200            permissions: HashMap::new(),
201            cloud: None,
202            models: ModelRouting::default(),
203            eruka: crate::eruka_bridge::ErukaConfig::default(),
204            use_ares_backend: false,
205            use_coordinator: false,
206            skills_repo: None,
207            local_first: false,
208            local_endpoint: None,
209        }
210    }
211}
212
213impl PawanConfig {
214    /// Load configuration from file
215    pub fn load(path: Option<&PathBuf>) -> crate::Result<Self> {
216        let config_path = path.cloned().or_else(|| {
217            // 1. pawan.toml in CWD
218            let pawan_toml = PathBuf::from("pawan.toml");
219            if pawan_toml.exists() {
220                return Some(pawan_toml);
221            }
222
223            // 2. ares.toml in CWD
224            let ares_toml = PathBuf::from("ares.toml");
225            if ares_toml.exists() {
226                return Some(ares_toml);
227            }
228
229            // 3. Global user config: ~/.config/pawan/pawan.toml
230            if let Some(home) = dirs::home_dir() {
231                let global = home.join(".config/pawan/pawan.toml");
232                if global.exists() {
233                    return Some(global);
234                }
235            }
236
237            None
238        });
239
240        match config_path {
241            Some(path) => {
242                let content = std::fs::read_to_string(&path).map_err(|e| {
243                    crate::PawanError::Config(format!("Failed to read {}: {}", path.display(), e))
244                })?;
245
246                // Check if this is ares.toml (look for [pawan] section)
247                if path.file_name().map(|n| n == "ares.toml").unwrap_or(false) {
248                    // Parse as TOML and extract [pawan] section
249                    let value: toml::Value = toml::from_str(&content).map_err(|e| {
250                        crate::PawanError::Config(format!(
251                            "Failed to parse {}: {}",
252                            path.display(),
253                            e
254                        ))
255                    })?;
256
257                    if let Some(pawan_section) = value.get("pawan") {
258                        let config: PawanConfig =
259                            pawan_section.clone().try_into().map_err(|e| {
260                                crate::PawanError::Config(format!(
261                                    "Failed to parse [pawan] section: {}",
262                                    e
263                                ))
264                            })?;
265                        return Ok(config);
266                    }
267
268                    // No [pawan] section, use defaults
269                    Ok(Self::default())
270                } else {
271                    // Parse as pawan.toml
272                    let mut config: PawanConfig = toml::from_str(&content).map_err(|e| {
273                        crate::PawanError::Config(format!(
274                            "Failed to parse {}: {}",
275                            path.display(),
276                            e
277                        ))
278                    })?;
279
280                    // Migrate config to latest version
281                    let migration_result = migrate_to_latest(&mut config, Some(&path));
282                    if migration_result.migrated {
283                        tracing::info!(
284                            from_version = migration_result.from_version,
285                            to_version = migration_result.to_version,
286                            backup = ?migration_result.backup_path,
287                            "Config migrated"
288                        );
289
290                        // Save migrated config
291                        if let Err(e) = save_config(&config, &path) {
292                            tracing::warn!(error = %e, "Failed to save migrated config");
293                        }
294                    }
295
296                    Ok(config)
297                }
298            }
299            None => Ok(Self::default()),
300        }
301    }
302
303    /// Apply environment variable overrides (PAWAN_MODEL, PAWAN_PROVIDER, etc.)
304    pub fn apply_env_overrides(&mut self) {
305        if let Ok(model) = std::env::var("PAWAN_MODEL") {
306            self.model = model;
307        }
308        if let Ok(provider) = std::env::var("PAWAN_PROVIDER") {
309            match provider.to_lowercase().as_str() {
310                "nvidia" | "nim" => self.provider = LlmProvider::Nvidia,
311                "ollama" => self.provider = LlmProvider::Ollama,
312                "openai" => self.provider = LlmProvider::OpenAI,
313                "mlx" | "mlx-lm" => self.provider = LlmProvider::Mlx,
314                _ => tracing::warn!(
315                    provider = provider.as_str(),
316                    "Unknown PAWAN_PROVIDER, ignoring"
317                ),
318            }
319        }
320        if let Ok(temp) = std::env::var("PAWAN_TEMPERATURE") {
321            if let Ok(t) = temp.parse::<f32>() {
322                self.temperature = t;
323            }
324        }
325        if let Ok(tokens) = std::env::var("PAWAN_MAX_TOKENS") {
326            if let Ok(t) = tokens.parse::<usize>() {
327                self.max_tokens = t;
328            }
329        }
330        if let Ok(iters) = std::env::var("PAWAN_MAX_ITERATIONS") {
331            if let Ok(i) = iters.parse::<usize>() {
332                self.max_tool_iterations = i;
333            }
334        }
335        if let Ok(ctx) = std::env::var("PAWAN_MAX_CONTEXT_TOKENS") {
336            if let Ok(c) = ctx.parse::<usize>() {
337                self.max_context_tokens = c;
338            }
339        }
340        if let Ok(models) = std::env::var("PAWAN_FALLBACK_MODELS") {
341            self.fallback_models = models
342                .split(',')
343                .map(|s| s.trim().to_string())
344                .filter(|s| !s.is_empty())
345                .collect();
346        }
347        if let Ok(chars) = std::env::var("PAWAN_MAX_RESULT_CHARS") {
348            if let Ok(c) = chars.parse::<usize>() {
349                self.max_result_chars = c;
350            }
351        }
352    }
353
354    /// Get target by name
355    pub fn get_target(&self, name: &str) -> Option<&TargetConfig> {
356        self.targets.get(name)
357    }
358
359    /// Get the system prompt, with optional project context injection.
360    /// Loads from PAWAN.md, AGENTS.md, CLAUDE.md, or .pawan/context.md.
361    pub fn get_system_prompt(&self) -> String {
362        match self.get_system_prompt_checked() {
363            Ok(p) => p,
364            Err(e) => {
365                tracing::error!("Failed to load project context for system prompt: {}", e);
366                self.system_prompt
367                    .clone()
368                    .unwrap_or_else(|| DEFAULT_SYSTEM_PROMPT.to_string())
369            }
370        }
371    }
372
373    /// Checked variant of `get_system_prompt` that rejects suspicious context
374    /// files with a clear error.
375    pub fn get_system_prompt_checked(&self) -> crate::Result<String> {
376        let base = self
377            .system_prompt
378            .clone()
379            .unwrap_or_else(|| DEFAULT_SYSTEM_PROMPT.to_string());
380
381        let mut prompt = base;
382
383        if let Some((filename, ctx)) = Self::load_context_file()? {
384            prompt = format!(
385                "{}
386
387## Project Context (from {})
388
389{}",
390                prompt, filename, ctx
391            );
392        }
393
394        if let Some(skill_ctx) = Self::load_skill_context() {
395            prompt = format!(
396                "{}
397
398## Active Skill (from SKILL.md)
399
400{}",
401                prompt, skill_ctx
402            );
403        }
404
405        #[cfg(feature = "memory")]
406        {
407            if let Ok(store) = crate::memory::MemoryStore::new_default() {
408                prompt = crate::memory::inject_memory_guidance_into_prompt(prompt, &store);
409            }
410        }
411
412        Ok(prompt)
413    }
414
415    fn scan_context_file(content: &str, source: &str) -> crate::Result<String> {
416        // Check for suspicious patterns
417        let suspicious = [
418            "IGNORE ALL PREVIOUS",
419            "DISREGARD ALL",
420            "OVERRIDE",
421            "You are now",
422            "Your new role",
423            "IMPORTANT: do not",
424            "<system-directive>",
425            "<role>",
426            "<contract>",
427            // Invisible unicode
428            "\u{200B}",
429            "\u{200C}",
430            "\u{200D}",
431            "\u{FEFF}",
432            "\u{202E}",
433            "\u{2060}",
434            "\u{2061}",
435            "\u{2062}",
436        ];
437
438        let upper = content.to_uppercase();
439        let allow = source == "AGENTS.md" || source == "CLAUDE.md";
440
441        for pattern in &suspicious {
442            let hit = if pattern.is_ascii() {
443                upper.contains(&pattern.to_uppercase())
444            } else {
445                content.contains(pattern)
446            };
447
448            if hit {
449                tracing::warn!(source = %source, pattern = %pattern, "prompt injection pattern detected");
450                if allow {
451                    continue;
452                }
453                return Err(crate::PawanError::Config(format!(
454                    "Suspicious content in {}: contains '{}'",
455                    source, pattern
456                )));
457            }
458        }
459
460        Ok(content.to_string())
461    }
462
463    /// Load project context file from current directory (if it exists).
464    /// Checks PAWAN.md, AGENTS.md (cross-tool standard), CLAUDE.md, then .pawan/context.md.
465    /// Returns (filename, content) of the first found file.
466    fn load_context_file() -> crate::Result<Option<(String, String)>> {
467        for path in &["PAWAN.md", "AGENTS.md", "CLAUDE.md", ".pawan/context.md"] {
468            let p = PathBuf::from(path);
469            if p.exists() {
470                let bytes = std::fs::read(&p).map_err(crate::PawanError::Io)?;
471                let content = String::from_utf8(bytes).map_err(|_| {
472                    crate::PawanError::Config(format!(
473                        "Suspicious content in {}: file is not valid UTF-8 (binary?)",
474                        path
475                    ))
476                })?;
477
478                let content = Self::scan_context_file(&content, path)?;
479                if !content.trim().is_empty() {
480                    return Ok(Some((path.to_string(), content)));
481                }
482            }
483        }
484        Ok(None)
485    }
486
487    /// Load SKILL.md files from the project using thulp-skill-files.
488    /// Returns a summary of discovered skills for context injection.
489    /// Sibling of `load_context_file`; only called from `get_system_prompt`.
490    fn load_skill_context() -> Option<String> {
491        use thulp_skill_files::SkillFile;
492
493        let skill_path = std::path::Path::new("SKILL.md");
494        if !skill_path.exists() {
495            return None;
496        }
497
498        match SkillFile::parse(skill_path) {
499            Ok(skill) => {
500                let name = skill.effective_name();
501                let desc = skill
502                    .frontmatter
503                    .description
504                    .as_deref()
505                    .unwrap_or("no description");
506                let tools_str = match &skill.frontmatter.allowed_tools {
507                    Some(tools) => tools.join(", "),
508                    None => "all".to_string(),
509                };
510                Some(format!(
511                    "[Skill: {}] {}\nAllowed tools: {}\n---\n{}",
512                    name, desc, tools_str, skill.content
513                ))
514            }
515            Err(e) => {
516                tracing::warn!("Failed to parse SKILL.md: {}", e);
517                None
518            }
519        }
520    }
521
522    /// Resolve the effective skills repository path using the dstack pattern:
523    /// env var > config field > default `~/.config/pawan/skills` > None.
524    ///
525    /// Returns `Some(path)` only if the resolved path exists as a directory.
526    /// This allows public pawan repos to link to private skill libraries
527    /// without embedding them — the path is configured per-machine.
528    pub fn resolve_skills_repo(&self) -> Option<PathBuf> {
529        // 1. Environment variable has highest priority
530        if let Ok(env_path) = std::env::var("PAWAN_SKILLS_REPO") {
531            let p = PathBuf::from(env_path);
532            if p.is_dir() {
533                return Some(p);
534            }
535            tracing::warn!(path = %p.display(), "PAWAN_SKILLS_REPO set but directory does not exist");
536        }
537
538        // 2. Config field
539        if let Some(ref p) = self.skills_repo {
540            if p.is_dir() {
541                return Some(p.clone());
542            }
543            tracing::warn!(path = %p.display(), "config.skills_repo set but directory does not exist");
544        }
545
546        // 3. Default: ~/.config/pawan/skills
547        if let Some(home) = dirs::home_dir() {
548            let default = home.join(".config").join("pawan").join("skills");
549            if default.is_dir() {
550                return Some(default);
551            }
552        }
553
554        None
555    }
556
557    /// Auto-discover MCP server binaries in PATH and register any that aren't
558    /// already configured. Returns the names of newly-discovered servers.
559    ///
560    /// Supported auto-discovery targets:
561    /// - `eruka-mcp` — Eruka context memory (anti-hallucination knowledge store)
562    /// - `daedra` — web search across 9 backends
563    /// - `deagle-mcp` — deagle code intelligence graph
564    ///
565    /// Existing entries in the `mcp` HashMap are never overwritten. This makes
566    /// the auto-discovery idempotent and safe to call at every agent startup.
567    pub fn auto_discover_mcp_servers(&mut self) -> Vec<String> {
568        let mut discovered = Vec::new();
569
570        // eruka-mcp: context memory for anti-hallucination
571        if !self.mcp.contains_key("eruka") && which::which("eruka-mcp").is_ok() {
572            self.mcp.insert(
573                "eruka".to_string(),
574                McpServerEntry {
575                    command: "eruka-mcp".to_string(),
576                    args: vec!["--transport".to_string(), "stdio".to_string()],
577                    env: HashMap::new(),
578                    enabled: true,
579                },
580            );
581            discovered.push("eruka".to_string());
582            tracing::info!("auto-discovered eruka-mcp");
583        }
584
585        // daedra: web search with 9 backends + fallback
586        if !self.mcp.contains_key("daedra") && which::which("daedra").is_ok() {
587            self.mcp.insert(
588                "daedra".to_string(),
589                McpServerEntry {
590                    command: "daedra".to_string(),
591                    args: vec![
592                        "serve".to_string(),
593                        "--transport".to_string(),
594                        "stdio".to_string(),
595                        "--quiet".to_string(),
596                    ],
597                    env: HashMap::new(),
598                    enabled: true,
599                },
600            );
601            discovered.push("daedra".to_string());
602            tracing::info!("auto-discovered daedra");
603        }
604
605        // deagle-mcp: graph-backed code intelligence
606        if !self.mcp.contains_key("deagle") && which::which("deagle-mcp").is_ok() {
607            self.mcp.insert(
608                "deagle".to_string(),
609                McpServerEntry {
610                    command: "deagle-mcp".to_string(),
611                    args: vec!["--transport".to_string(), "stdio".to_string()],
612                    env: HashMap::new(),
613                    enabled: true,
614                },
615            );
616            discovered.push("deagle".to_string());
617            tracing::info!("auto-discovered deagle-mcp");
618        }
619
620        discovered
621    }
622
623    /// Discover all SKILL.md files in the configured skills repository using
624    /// thulp-skill-files SkillLoader.
625    ///
626    /// Returns a list of `(skill_name, description, file_path)` tuples. The
627    /// caller is responsible for deciding which skills to inject into the
628    /// system prompt or present to the user.
629    ///
630    /// The skills repository is never compiled into the pawan binary — this
631    /// enables the "public repo links to private skills via config" pattern
632    /// used by dstack for `dirmacs/skills`.
633    pub fn discover_skills_from_repo(&self) -> Vec<(String, String, PathBuf)> {
634        use thulp_skill_files::SkillFile;
635
636        let repo = match self.resolve_skills_repo() {
637            Some(r) => r,
638            None => return Vec::new(),
639        };
640
641        let mut results = Vec::new();
642        let walker = match std::fs::read_dir(&repo) {
643            Ok(w) => w,
644            Err(e) => {
645                tracing::warn!(path = %repo.display(), error = %e, "failed to read skills repo");
646                return Vec::new();
647            }
648        };
649
650        for entry in walker.flatten() {
651            let path = entry.path();
652            // Each skill is a directory containing SKILL.md
653            let skill_file = path.join("SKILL.md");
654            if !skill_file.is_file() {
655                continue;
656            }
657            match SkillFile::parse(&skill_file) {
658                Ok(skill) => {
659                    let name = skill.effective_name();
660                    let desc = skill
661                        .frontmatter
662                        .description
663                        .clone()
664                        .unwrap_or_else(|| "(no description)".to_string());
665                    results.push((name, desc, skill_file));
666                }
667                Err(e) => {
668                    tracing::debug!(path = %skill_file.display(), error = %e, "skip unparseable skill");
669                }
670            }
671        }
672
673        results.sort_by(|a, b| a.0.cmp(&b.0));
674        results
675    }
676
677    /// Check if thinking mode should be enabled.
678    /// Applicable to DeepSeek, Gemma-4, GLM, Qwen, and Mistral Small 4+ models on NIM.
679    pub fn use_thinking_mode(&self) -> bool {
680        self.reasoning_mode
681            && (self.model.contains("deepseek")
682                || self.model.contains("gemma")
683                || self.model.contains("glm")
684                || self.model.contains("qwen")
685                || self.model.contains("mistral-small-4"))
686    }
687}