Skip to main content

batuta/agent/
manifest.rs

1//! Agent manifest configuration.
2//!
3//! Defines the TOML-based configuration for agent instances.
4//! Includes model path, resource quotas (Muda elimination),
5//! granted capabilities (Poka-Yoke), and privacy tier.
6
7use serde::{Deserialize, Serialize};
8use std::path::PathBuf;
9
10use super::capability::Capability;
11use crate::serve::backends::PrivacyTier;
12
13/// Agent configuration loaded from TOML.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15#[serde(default)]
16pub struct AgentManifest {
17    /// Human-readable agent name.
18    pub name: String,
19    /// Semantic version.
20    pub version: String,
21    /// Description of what this agent does.
22    pub description: String,
23    /// LLM model configuration.
24    pub model: ModelConfig,
25    /// Resource quotas (Muda elimination).
26    pub resources: ResourceQuota,
27    /// Granted capabilities (Poka-Yoke).
28    pub capabilities: Vec<Capability>,
29    /// Privacy tier. Default: Sovereign (local-only).
30    pub privacy: PrivacyTier,
31    /// External MCP servers to connect to (agents-mcp feature). [F-022]
32    #[cfg(feature = "agents-mcp")]
33    #[serde(default)]
34    pub mcp_servers: Vec<McpServerConfig>,
35}
36
37impl Default for AgentManifest {
38    fn default() -> Self {
39        Self {
40            name: "unnamed-agent".into(),
41            version: "0.1.0".into(),
42            description: String::new(),
43            model: ModelConfig::default(),
44            resources: ResourceQuota::default(),
45            capabilities: vec![Capability::Rag, Capability::Memory],
46            privacy: PrivacyTier::Sovereign,
47            #[cfg(feature = "agents-mcp")]
48            mcp_servers: Vec::new(),
49        }
50    }
51}
52
53/// LLM model configuration.
54#[derive(Debug, Clone, Serialize, Deserialize)]
55#[serde(default)]
56pub struct ModelConfig {
57    /// Path to local model file (GGUF/APR/SafeTensors).
58    pub model_path: Option<PathBuf>,
59    /// Remote model identifier (Phase 2, for spillover).
60    pub remote_model: Option<String>,
61    /// `HuggingFace` repo ID for auto-pull (Phase 2).
62    /// When set and `model_path` is None, resolves via `apr pull`.
63    pub model_repo: Option<String>,
64    /// Quantization variant for auto-pull (e.g., `q4_k_m`).
65    pub model_quantization: Option<String>,
66    /// Maximum tokens per completion.
67    pub max_tokens: u32,
68    /// Sampling temperature.
69    pub temperature: f32,
70    /// System prompt injected at start of conversation.
71    pub system_prompt: String,
72    /// Context window size override (auto-detected if None).
73    pub context_window: Option<usize>,
74}
75
76impl Default for ModelConfig {
77    fn default() -> Self {
78        Self {
79            model_path: None,
80            remote_model: None,
81            model_repo: None,
82            model_quantization: None,
83            max_tokens: 4096,
84            temperature: 0.3,
85            system_prompt: "You are a helpful assistant.".into(),
86            context_window: None,
87        }
88    }
89}
90
91impl ModelConfig {
92    /// Resolve the effective model path from explicit config only.
93    ///
94    /// Resolution order:
95    /// 1. Explicit `model_path` — return as-is
96    /// 2. `model_repo` — resolve via pacha cache
97    /// 3. Neither — return None
98    ///
99    /// Note: auto-discovery from standard paths is done separately
100    /// in `cmd_code` (via `discover_model()`) to avoid side effects
101    /// in agent manifest validation and tests.
102    pub fn resolve_model_path(&self) -> Option<PathBuf> {
103        if let Some(ref path) = self.model_path {
104            return Some(path.clone());
105        }
106        if let Some(ref repo) = self.model_repo {
107            let quant = self.model_quantization.as_deref().unwrap_or("q4_k_m");
108            let cache_dir = dirs::cache_dir()
109                .unwrap_or_else(|| PathBuf::from("/tmp"))
110                .join("pacha")
111                .join("models");
112            let filename = format!("{}-{}.gguf", repo.replace('/', "--"), quant,);
113            return Some(cache_dir.join(filename));
114        }
115        None
116    }
117
118    /// Resolve model path with auto-discovery fallback.
119    ///
120    /// Same as `resolve_model_path()` but also scans standard paths
121    /// (`~/.apr/models/`, `~/.cache/huggingface/`, `./models/`) for
122    /// APR/GGUF files. Used by `cmd_code` for the interactive REPL.
123    pub fn resolve_model_path_with_discovery(&self) -> Option<PathBuf> {
124        self.resolve_model_path().or_else(Self::discover_model)
125    }
126
127    /// Check if model needs to be downloaded (auto-pull).
128    ///
129    /// Returns `Some(repo)` if `model_repo` is set but the
130    /// resolved cache path does not exist on disk.
131    pub fn needs_pull(&self) -> Option<&str> {
132        if self.model_path.is_some() {
133            return None;
134        }
135        if let Some(ref repo) = self.model_repo {
136            if let Some(path) = self.resolve_model_path() {
137                if !path.exists() {
138                    return Some(repo.as_str());
139                }
140            }
141        }
142        None
143    }
144
145    /// Discover a local model by scanning standard paths.
146    ///
147    /// Search order (per apr-code.md §5.1):
148    /// 1. `~/.apr/models/`
149    /// 2. `~/.cache/huggingface/` (hub models)
150    /// 3. `./models/` (project-local)
151    ///
152    /// Within each directory, prefer `.apr` over `.gguf` (APR is the
153    /// stack's native format — faster loading, row-major layout).
154    /// Files sorted by modification time (newest first).
155    ///
156    /// **PMAT-150 (Jidoka):** APR files are validated at discovery time —
157    /// if an APR file lacks an embedded tokenizer, it is deprioritized
158    /// so GGUF files are tried first. This prevents the user from hitting
159    /// a dead-end error when the only APR model is broken.
160    pub fn discover_model() -> Option<PathBuf> {
161        // (path, mtime, is_apr, is_valid)
162        let mut candidates: Vec<(PathBuf, std::time::SystemTime, bool, bool)> = Vec::new();
163
164        let search_dirs = Self::model_search_dirs();
165        for dir in &search_dirs {
166            if !dir.is_dir() {
167                continue;
168            }
169            if let Ok(entries) = std::fs::read_dir(dir) {
170                for entry in entries.flatten() {
171                    let path = entry.path();
172                    let is_apr = path.extension().is_some_and(|e| e == "apr");
173                    let is_gguf = path.extension().is_some_and(|e| e == "gguf");
174                    if !is_apr && !is_gguf {
175                        continue;
176                    }
177                    let mtime = entry
178                        .metadata()
179                        .ok()
180                        .and_then(|m| m.modified().ok())
181                        .unwrap_or(std::time::UNIX_EPOCH);
182
183                    // PMAT-150: validate APR files at discovery (Jidoka).
184                    // Invalid APR → deprioritize (valid=false) so GGUF wins.
185                    let is_valid = super::driver::validate::is_valid_model_file(&path);
186
187                    candidates.push((path, mtime, is_apr, is_valid));
188                }
189            }
190        }
191
192        if candidates.is_empty() {
193            return None;
194        }
195
196        // Sort: valid first, then newest mtime (user intent), then APR preferred.
197        // PMAT-185: mtime before format — the model the user most recently
198        // downloaded is more likely their intended default. A valid-but-broken-
199        // for-tool-use APR should not shadow a newer GGUF with better quality.
200        candidates.sort_by(|a, b| {
201            b.3.cmp(&a.3) // valid preferred (true > false)
202                .then_with(|| b.1.cmp(&a.1)) // newest first (user intent)
203                .then_with(|| b.2.cmp(&a.2)) // APR preferred as tiebreaker
204        });
205
206        Some(candidates[0].0.clone())
207    }
208
209    /// Sort model candidates by priority. Extracted for contract testing (PMAT-188).
210    ///
211    /// Sort order: valid > newest mtime > APR format (tiebreaker only).
212    #[cfg(test)]
213    pub(crate) fn sort_candidates(
214        candidates: &mut [(std::path::PathBuf, std::time::SystemTime, bool, bool)],
215    ) {
216        candidates.sort_by(|a, b| {
217            b.3.cmp(&a.3) // valid preferred
218                .then_with(|| b.1.cmp(&a.1)) // newest first
219                .then_with(|| b.2.cmp(&a.2)) // APR tiebreaker
220        });
221    }
222
223    /// Standard model search directories.
224    pub fn model_search_dirs() -> Vec<PathBuf> {
225        let mut dirs = Vec::new();
226        if let Some(home) = dirs::home_dir() {
227            dirs.push(home.join(".apr").join("models"));
228            dirs.push(home.join(".cache").join("huggingface"));
229        }
230        dirs.push(PathBuf::from("./models"));
231        dirs
232    }
233
234    /// Auto-pull model via `apr pull` subprocess.
235    ///
236    /// Invokes `apr pull <repo>` with a configurable timeout.
237    /// The `apr` CLI handles caching internally at
238    /// `~/.cache/pacha/models/`. Returns the resolved cache path
239    /// on success.
240    ///
241    /// Jidoka: stops on subprocess failure rather than continuing
242    /// with a missing model.
243    pub fn auto_pull(&self, timeout_secs: u64) -> Result<PathBuf, AutoPullError> {
244        let repo = self.model_repo.as_deref().ok_or(AutoPullError::NoRepo)?;
245
246        let target_path = self.resolve_model_path().ok_or(AutoPullError::NoRepo)?;
247
248        // Check if `apr` binary is available
249        let apr_path = which_apr()?;
250
251        // Build model reference: repo or repo:quant
252        let model_ref = match self.model_quantization.as_deref() {
253            Some(q) => format!("{repo}:{q}"),
254            None => repo.to_string(),
255        };
256
257        let mut child = std::process::Command::new(&apr_path)
258            .args(["pull", &model_ref])
259            .stdout(std::process::Stdio::inherit())
260            .stderr(std::process::Stdio::piped())
261            .spawn()
262            .map_err(|e| AutoPullError::Subprocess(format!("cannot spawn apr pull: {e}")))?;
263
264        let output = wait_with_timeout(&mut child, timeout_secs)?;
265
266        if !output.status.success() {
267            let stderr = String::from_utf8_lossy(&output.stderr);
268            return Err(AutoPullError::Subprocess(format!(
269                "apr pull exited with {}: {}",
270                output.status,
271                stderr.trim(),
272            )));
273        }
274
275        if !target_path.exists() {
276            return Err(AutoPullError::Subprocess(
277                "apr pull completed but model file not found at expected path".into(),
278            ));
279        }
280
281        Ok(target_path)
282    }
283}
284
285/// Errors from model auto-pull operations.
286#[derive(Debug)]
287pub enum AutoPullError {
288    /// No `model_repo` configured.
289    NoRepo,
290    /// `apr` binary not found in PATH.
291    NotInstalled,
292    /// Subprocess execution failed.
293    Subprocess(String),
294    /// Filesystem I/O error.
295    Io(String),
296}
297
298impl std::fmt::Display for AutoPullError {
299    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
300        match self {
301            Self::NoRepo => write!(f, "no model_repo configured"),
302            Self::NotInstalled => {
303                write!(f, "apr binary not found in PATH; install with: cargo install apr-cli")
304            }
305            Self::Subprocess(msg) | Self::Io(msg) => write!(f, "{msg}"),
306        }
307    }
308}
309
310impl std::error::Error for AutoPullError {}
311
312/// Locate the `apr` binary in PATH.
313fn which_apr() -> Result<PathBuf, AutoPullError> {
314    // Check common names: `apr`, `apr-cli`
315    for name in &["apr", "apr-cli"] {
316        if let Ok(path) = which::which(name) {
317            return Ok(path);
318        }
319    }
320    Err(AutoPullError::NotInstalled)
321}
322
323/// Wait for a child process with a polling timeout.
324fn wait_with_timeout(
325    child: &mut std::process::Child,
326    timeout_secs: u64,
327) -> Result<std::process::Output, AutoPullError> {
328    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
329
330    loop {
331        match child.try_wait() {
332            Ok(Some(status)) => {
333                let stderr = child
334                    .stderr
335                    .take()
336                    .map(|mut s| {
337                        let mut buf = Vec::new();
338                        std::io::Read::read_to_end(&mut s, &mut buf).ok();
339                        buf
340                    })
341                    .unwrap_or_default();
342                return Ok(std::process::Output { status, stdout: Vec::new(), stderr });
343            }
344            Ok(None) => {
345                if std::time::Instant::now() >= deadline {
346                    child.kill().ok();
347                    return Err(AutoPullError::Subprocess(format!(
348                        "apr pull timed out after {timeout_secs}s"
349                    )));
350                }
351                std::thread::sleep(std::time::Duration::from_millis(500));
352            }
353            Err(e) => {
354                return Err(AutoPullError::Subprocess(format!("wait error: {e}")));
355            }
356        }
357    }
358}
359
360/// Resource quotas (Muda elimination).
361#[derive(Debug, Clone, Serialize, Deserialize)]
362#[serde(default)]
363pub struct ResourceQuota {
364    /// Maximum loop iterations per invocation.
365    pub max_iterations: u32,
366    /// Maximum tool calls per invocation.
367    pub max_tool_calls: u32,
368    /// Maximum cost in USD (for hybrid deployments).
369    pub max_cost_usd: f64,
370    /// Maximum cumulative token budget (input+output). None = unlimited.
371    #[serde(default)]
372    pub max_tokens_budget: Option<u64>,
373}
374
375impl Default for ResourceQuota {
376    fn default() -> Self {
377        Self { max_iterations: 20, max_tool_calls: 50, max_cost_usd: 0.0, max_tokens_budget: None }
378    }
379}
380
381/// Configuration for an external MCP server connection. [F-022]
382#[cfg(feature = "agents-mcp")]
383#[derive(Debug, Clone, Serialize, Deserialize)]
384pub struct McpServerConfig {
385    /// MCP server name (used for capability matching).
386    pub name: String,
387    /// Transport type (stdio, SSE, WebSocket).
388    pub transport: McpTransport,
389    /// For stdio: command + args to launch the server process.
390    #[serde(default)]
391    pub command: Vec<String>,
392    /// For SSE/WebSocket: URL to connect to.
393    pub url: Option<String>,
394    /// Tool names granted from this server. `["*"]` grants all.
395    #[serde(default)]
396    pub capabilities: Vec<String>,
397}
398
399/// MCP transport mechanism. [F-022]
400#[cfg(feature = "agents-mcp")]
401#[derive(Debug, Clone, Serialize, Deserialize)]
402#[serde(rename_all = "snake_case")]
403pub enum McpTransport {
404    /// Subprocess communication via stdin/stdout.
405    Stdio,
406    /// Server-Sent Events over HTTP.
407    Sse,
408    /// WebSocket full-duplex.
409    WebSocket,
410}
411
412impl AgentManifest {
413    /// Parse an agent manifest from TOML string.
414    pub fn from_toml(toml_str: &str) -> Result<Self, toml::de::Error> {
415        toml::from_str(toml_str)
416    }
417
418    /// Validate the manifest for consistency.
419    pub fn validate(&self) -> Result<(), Vec<String>> {
420        let mut errors = Vec::new();
421
422        if self.name.is_empty() {
423            errors.push("name must not be empty".into());
424        }
425        if self.resources.max_iterations == 0 {
426            errors.push("max_iterations must be > 0".into());
427        }
428        if self.resources.max_tool_calls == 0 {
429            errors.push("max_tool_calls must be > 0".into());
430        }
431        if self.model.max_tokens == 0 {
432            errors.push("max_tokens must be > 0".into());
433        }
434        if self.model.temperature < 0.0 || self.model.temperature > 2.0 {
435            errors.push("temperature must be in [0.0, 2.0]".into());
436        }
437        if self.privacy == PrivacyTier::Sovereign && self.model.remote_model.is_some() {
438            errors.push("sovereign privacy tier cannot use remote_model".into());
439        }
440        if self.model.model_repo.is_some() && self.model.model_path.is_some() {
441            errors.push("model_repo and model_path are mutually exclusive".into());
442        }
443        #[cfg(feature = "agents-mcp")]
444        self.validate_mcp_servers(&mut errors);
445
446        if errors.is_empty() {
447            Ok(())
448        } else {
449            Err(errors)
450        }
451    }
452
453    /// Validate MCP server configurations (Poka-Yoke).
454    #[cfg(feature = "agents-mcp")]
455    fn validate_mcp_servers(&self, errors: &mut Vec<String>) {
456        for server in &self.mcp_servers {
457            if server.name.is_empty() {
458                errors.push("MCP server name must not be empty".into());
459            }
460            if self.privacy == PrivacyTier::Sovereign
461                && matches!(server.transport, McpTransport::Sse | McpTransport::WebSocket)
462            {
463                errors.push(format!(
464                    "sovereign privacy tier blocks network MCP transport for server '{}'",
465                    server.name,
466                ));
467            }
468            if matches!(server.transport, McpTransport::Stdio) && server.command.is_empty() {
469                errors.push(format!(
470                    "MCP server '{}' uses stdio transport but has no command",
471                    server.name,
472                ));
473            }
474        }
475    }
476}
477
478#[cfg(test)]
479#[path = "manifest_tests.rs"]
480mod tests;
481
482#[cfg(test)]
483#[path = "manifest_tests_validation.rs"]
484mod tests_validation;
485
486#[cfg(test)]
487#[path = "manifest_tests_discovery.rs"]
488mod tests_discovery;