1use serde::{Deserialize, Serialize};
8use std::path::PathBuf;
9
10use super::capability::Capability;
11use crate::serve::backends::PrivacyTier;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
15#[serde(default)]
16pub struct AgentManifest {
17 pub name: String,
19 pub version: String,
21 pub description: String,
23 pub model: ModelConfig,
25 pub resources: ResourceQuota,
27 pub capabilities: Vec<Capability>,
29 pub privacy: PrivacyTier,
31 #[cfg(feature = "agents-mcp")]
33 #[serde(default)]
34 pub mcp_servers: Vec<McpServerConfig>,
35 #[serde(default)]
48 pub hooks: Vec<super::hooks::HookConfig>,
49 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
82#[serde(default)]
83pub struct ModelConfig {
84 pub model_path: Option<PathBuf>,
86 pub remote_model: Option<String>,
88 pub model_repo: Option<String>,
91 pub model_quantization: Option<String>,
93 pub max_tokens: u32,
95 pub temperature: f32,
97 pub system_prompt: String,
99 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 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 pub fn resolve_model_path_with_discovery(&self) -> Option<PathBuf> {
151 self.resolve_model_path().or_else(Self::discover_model)
152 }
153
154 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 pub fn discover_model() -> Option<PathBuf> {
188 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 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 candidates.sort_by(|a, b| {
228 b.3.cmp(&a.3) .then_with(|| b.1.cmp(&a.1)) .then_with(|| b.2.cmp(&a.2)) });
232
233 Some(candidates[0].0.clone())
234 }
235
236 #[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) .then_with(|| b.1.cmp(&a.1)) .then_with(|| b.2.cmp(&a.2)) });
248 }
249
250 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 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 let apr_path = which_apr()?;
277
278 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#[derive(Debug)]
314pub enum AutoPullError {
315 NoRepo,
317 NotInstalled,
319 Subprocess(String),
321 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
339fn which_apr() -> Result<PathBuf, AutoPullError> {
341 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
350fn 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#[derive(Debug, Clone, Serialize, Deserialize)]
389#[serde(default)]
390pub struct ResourceQuota {
391 pub max_iterations: u32,
393 pub max_tool_calls: u32,
395 pub max_cost_usd: f64,
397 #[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#[cfg(feature = "agents-mcp")]
410#[derive(Debug, Clone, Serialize, Deserialize)]
411pub struct McpServerConfig {
412 pub name: String,
414 pub transport: McpTransport,
416 #[serde(default)]
418 pub command: Vec<String>,
419 pub url: Option<String>,
421 #[serde(default)]
423 pub capabilities: Vec<String>,
424}
425
426#[cfg(feature = "agents-mcp")]
428#[derive(Debug, Clone, Serialize, Deserialize)]
429#[serde(rename_all = "snake_case")]
430pub enum McpTransport {
431 Stdio,
433 Sse,
435 WebSocket,
437}
438
439impl AgentManifest {
440 pub fn from_toml(toml_str: &str) -> Result<Self, toml::de::Error> {
442 toml::from_str(toml_str)
443 }
444
445 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 #[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;