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