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}
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#[derive(Debug, Clone, Serialize, Deserialize)]
55#[serde(default)]
56pub struct ModelConfig {
57 pub model_path: Option<PathBuf>,
59 pub remote_model: Option<String>,
61 pub model_repo: Option<String>,
64 pub model_quantization: Option<String>,
66 pub max_tokens: u32,
68 pub temperature: f32,
70 pub system_prompt: String,
72 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 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 pub fn resolve_model_path_with_discovery(&self) -> Option<PathBuf> {
124 self.resolve_model_path().or_else(Self::discover_model)
125 }
126
127 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 pub fn discover_model() -> Option<PathBuf> {
161 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 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 candidates.sort_by(|a, b| {
201 b.3.cmp(&a.3) .then_with(|| b.1.cmp(&a.1)) .then_with(|| b.2.cmp(&a.2)) });
205
206 Some(candidates[0].0.clone())
207 }
208
209 #[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) .then_with(|| b.1.cmp(&a.1)) .then_with(|| b.2.cmp(&a.2)) });
221 }
222
223 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 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 let apr_path = which_apr()?;
250
251 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#[derive(Debug)]
287pub enum AutoPullError {
288 NoRepo,
290 NotInstalled,
292 Subprocess(String),
294 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
312fn which_apr() -> Result<PathBuf, AutoPullError> {
314 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
323fn 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#[derive(Debug, Clone, Serialize, Deserialize)]
362#[serde(default)]
363pub struct ResourceQuota {
364 pub max_iterations: u32,
366 pub max_tool_calls: u32,
368 pub max_cost_usd: f64,
370 #[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#[cfg(feature = "agents-mcp")]
383#[derive(Debug, Clone, Serialize, Deserialize)]
384pub struct McpServerConfig {
385 pub name: String,
387 pub transport: McpTransport,
389 #[serde(default)]
391 pub command: Vec<String>,
392 pub url: Option<String>,
394 #[serde(default)]
396 pub capabilities: Vec<String>,
397}
398
399#[cfg(feature = "agents-mcp")]
401#[derive(Debug, Clone, Serialize, Deserialize)]
402#[serde(rename_all = "snake_case")]
403pub enum McpTransport {
404 Stdio,
406 Sse,
408 WebSocket,
410}
411
412impl AgentManifest {
413 pub fn from_toml(toml_str: &str) -> Result<Self, toml::de::Error> {
415 toml::from_str(toml_str)
416 }
417
418 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 #[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;