1use serde::{Deserialize, Serialize};
8use std::path::{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| {
236 let a_pref = is_preferred_default_model(&a.0);
237 let b_pref = is_preferred_default_model(&b.0);
238 b.3.cmp(&a.3) .then_with(|| b_pref.cmp(&a_pref)) .then_with(|| b.1.cmp(&a.1)) .then_with(|| b.2.cmp(&a.2)) });
243
244 Some(candidates[0].0.clone())
245 }
246
247 #[cfg(test)]
251 pub(crate) fn sort_candidates(
252 candidates: &mut [(std::path::PathBuf, std::time::SystemTime, bool, bool)],
253 ) {
254 candidates.sort_by(|a, b| {
255 let a_pref = is_preferred_default_model(&a.0);
256 let b_pref = is_preferred_default_model(&b.0);
257 b.3.cmp(&a.3)
258 .then_with(|| b_pref.cmp(&a_pref))
259 .then_with(|| b.1.cmp(&a.1))
260 .then_with(|| b.2.cmp(&a.2))
261 });
262 }
263
264 pub fn model_search_dirs() -> Vec<PathBuf> {
266 let mut dirs = Vec::new();
267 if let Some(home) = dirs::home_dir() {
268 dirs.push(home.join(".apr").join("models"));
269 dirs.push(home.join(".cache").join("pacha").join("models"));
275 dirs.push(home.join(".cache").join("huggingface"));
276 }
277 dirs.push(PathBuf::from("./models"));
278 dirs
279 }
280
281 pub fn auto_pull(&self, timeout_secs: u64) -> Result<PathBuf, AutoPullError> {
291 let repo = self.model_repo.as_deref().ok_or(AutoPullError::NoRepo)?;
292
293 let target_path = self.resolve_model_path().ok_or(AutoPullError::NoRepo)?;
294
295 let apr_path = which_apr()?;
297
298 let model_ref = match self.model_quantization.as_deref() {
300 Some(q) => format!("{repo}:{q}"),
301 None => repo.to_string(),
302 };
303
304 let mut child = std::process::Command::new(&apr_path)
305 .args(["pull", &model_ref])
306 .stdout(std::process::Stdio::inherit())
307 .stderr(std::process::Stdio::piped())
308 .spawn()
309 .map_err(|e| AutoPullError::Subprocess(format!("cannot spawn apr pull: {e}")))?;
310
311 let output = wait_with_timeout(&mut child, timeout_secs)?;
312
313 if !output.status.success() {
314 let stderr = String::from_utf8_lossy(&output.stderr);
315 return Err(AutoPullError::Subprocess(format!(
316 "apr pull exited with {}: {}",
317 output.status,
318 stderr.trim(),
319 )));
320 }
321
322 if !target_path.exists() {
323 return Err(AutoPullError::Subprocess(
324 "apr pull completed but model file not found at expected path".into(),
325 ));
326 }
327
328 Ok(target_path)
329 }
330}
331
332fn is_preferred_default_model(path: &Path) -> bool {
346 const PREFERRED_NAME_TOKENS: &[&str] = &[
347 "qwen3-coder-30b-a3b",
349 "qwen3-coder-next",
352 "qwen2.5-coder-32b",
353 "qwen2.5-coder-14b",
354 ];
355 let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
356 return false;
357 };
358 let name_lc: String = name.to_ascii_lowercase();
359 PREFERRED_NAME_TOKENS.iter().any(|token| name_lc.contains(token))
360}
361
362#[derive(Debug)]
364pub enum AutoPullError {
365 NoRepo,
367 NotInstalled,
369 Subprocess(String),
371 Io(String),
373}
374
375impl std::fmt::Display for AutoPullError {
376 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
377 match self {
378 Self::NoRepo => write!(f, "no model_repo configured"),
379 Self::NotInstalled => {
380 write!(f, "apr binary not found in PATH; install with: cargo install apr-cli")
381 }
382 Self::Subprocess(msg) | Self::Io(msg) => write!(f, "{msg}"),
383 }
384 }
385}
386
387impl std::error::Error for AutoPullError {}
388
389fn which_apr() -> Result<PathBuf, AutoPullError> {
391 for name in &["apr", "apr-cli"] {
393 if let Ok(path) = which::which(name) {
394 return Ok(path);
395 }
396 }
397 Err(AutoPullError::NotInstalled)
398}
399
400fn wait_with_timeout(
402 child: &mut std::process::Child,
403 timeout_secs: u64,
404) -> Result<std::process::Output, AutoPullError> {
405 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
406
407 loop {
408 match child.try_wait() {
409 Ok(Some(status)) => {
410 let stderr = child
411 .stderr
412 .take()
413 .map(|mut s| {
414 let mut buf = Vec::new();
415 std::io::Read::read_to_end(&mut s, &mut buf).ok();
416 buf
417 })
418 .unwrap_or_default();
419 return Ok(std::process::Output { status, stdout: Vec::new(), stderr });
420 }
421 Ok(None) => {
422 if std::time::Instant::now() >= deadline {
423 child.kill().ok();
424 return Err(AutoPullError::Subprocess(format!(
425 "apr pull timed out after {timeout_secs}s"
426 )));
427 }
428 std::thread::sleep(std::time::Duration::from_millis(500));
429 }
430 Err(e) => {
431 return Err(AutoPullError::Subprocess(format!("wait error: {e}")));
432 }
433 }
434 }
435}
436
437#[derive(Debug, Clone, Serialize, Deserialize)]
439#[serde(default)]
440pub struct ResourceQuota {
441 pub max_iterations: u32,
443 pub max_tool_calls: u32,
445 pub max_cost_usd: f64,
447 #[serde(default)]
449 pub max_tokens_budget: Option<u64>,
450}
451
452impl Default for ResourceQuota {
453 fn default() -> Self {
454 Self { max_iterations: 20, max_tool_calls: 50, max_cost_usd: 0.0, max_tokens_budget: None }
455 }
456}
457
458#[cfg(feature = "agents-mcp")]
460#[derive(Debug, Clone, Serialize, Deserialize)]
461pub struct McpServerConfig {
462 pub name: String,
464 pub transport: McpTransport,
466 #[serde(default)]
468 pub command: Vec<String>,
469 pub url: Option<String>,
471 #[serde(default)]
473 pub capabilities: Vec<String>,
474}
475
476#[cfg(feature = "agents-mcp")]
478#[derive(Debug, Clone, Serialize, Deserialize)]
479#[serde(rename_all = "snake_case")]
480pub enum McpTransport {
481 Stdio,
483 Sse,
485 WebSocket,
487}
488
489impl AgentManifest {
490 pub fn from_toml(toml_str: &str) -> Result<Self, toml::de::Error> {
492 toml::from_str(toml_str)
493 }
494
495 pub fn validate(&self) -> Result<(), Vec<String>> {
497 let mut errors = Vec::new();
498
499 if self.name.is_empty() {
500 errors.push("name must not be empty".into());
501 }
502 if self.resources.max_iterations == 0 {
503 errors.push("max_iterations must be > 0".into());
504 }
505 if self.resources.max_tool_calls == 0 {
506 errors.push("max_tool_calls must be > 0".into());
507 }
508 if self.model.max_tokens == 0 {
509 errors.push("max_tokens must be > 0".into());
510 }
511 if self.model.temperature < 0.0 || self.model.temperature > 2.0 {
512 errors.push("temperature must be in [0.0, 2.0]".into());
513 }
514 if self.privacy == PrivacyTier::Sovereign && self.model.remote_model.is_some() {
515 errors.push("sovereign privacy tier cannot use remote_model".into());
516 }
517 if self.model.model_repo.is_some() && self.model.model_path.is_some() {
518 errors.push("model_repo and model_path are mutually exclusive".into());
519 }
520 #[cfg(feature = "agents-mcp")]
521 self.validate_mcp_servers(&mut errors);
522
523 if errors.is_empty() {
524 Ok(())
525 } else {
526 Err(errors)
527 }
528 }
529
530 #[cfg(feature = "agents-mcp")]
532 fn validate_mcp_servers(&self, errors: &mut Vec<String>) {
533 for server in &self.mcp_servers {
534 if server.name.is_empty() {
535 errors.push("MCP server name must not be empty".into());
536 }
537 if self.privacy == PrivacyTier::Sovereign
538 && matches!(server.transport, McpTransport::Sse | McpTransport::WebSocket)
539 {
540 errors.push(format!(
541 "sovereign privacy tier blocks network MCP transport for server '{}'",
542 server.name,
543 ));
544 }
545 if matches!(server.transport, McpTransport::Stdio) && server.command.is_empty() {
546 errors.push(format!(
547 "MCP server '{}' uses stdio transport but has no command",
548 server.name,
549 ));
550 }
551 }
552 }
553}
554
555#[cfg(test)]
556#[path = "manifest_tests.rs"]
557mod tests;
558
559#[cfg(test)]
560#[path = "manifest_tests_validation.rs"]
561mod tests_validation;
562
563#[cfg(test)]
564#[path = "manifest_tests_discovery.rs"]
565mod tests_discovery;