1use crate::registry::{Tool, ToolContext};
2use aegis_security::{check_command, check_path, sanitize_credentials, DangerLevel};
3use anyhow::Result;
4use async_trait::async_trait;
5use serde_json::{json, Value};
6use std::process::Stdio;
7use std::sync::Arc;
8
9fn kill_process_group(pid: Option<u32>) {
17 #[cfg(unix)]
18 if let Some(p) = pid {
19 unsafe {
21 libc::kill(-(p as i32), libc::SIGKILL);
22 }
23 }
24 #[cfg(not(unix))]
25 let _ = pid;
26}
27
28struct ChildKillGuard {
32 pid: Option<u32>,
33}
34impl ChildKillGuard {
35 fn disarm(&mut self) {
36 self.pid = None;
37 }
38}
39impl Drop for ChildKillGuard {
40 fn drop(&mut self) {
41 kill_process_group(self.pid);
42 }
43}
44
45fn is_risky_for_snapshot(cmd: &str) -> bool {
48 let l = cmd.to_lowercase();
49 const PATTERNS: &[&str] = &[
50 "mv ",
51 "dd ",
52 "truncate",
53 "mkfs",
54 "shred",
55 "git reset --hard",
56 "git clean",
57 "git checkout .",
58 "-delete",
59 ];
60 PATTERNS.iter().any(|p| l.contains(p))
61}
62
63pub struct TerminalTool;
64
65#[async_trait]
66impl Tool for TerminalTool {
67 fn name(&self) -> &str {
68 "terminal"
69 }
70 fn description(&self) -> &str {
71 "Execute a shell command and return stdout+stderr. Commands are security-checked."
72 }
73 fn parameters(&self) -> Value {
74 json!({
75 "type": "object",
76 "properties": {
77 "command": { "type": "string", "description": "Shell command to execute" },
78 "timeout": { "type": "integer", "description": "Timeout in seconds (default 300)" }
79 },
80 "required": ["command"]
81 })
82 }
83 async fn execute(&self, args: Value, ctx: &ToolContext<'_>) -> Result<String> {
84 let cmd = args["command"].as_str().unwrap_or("");
85 let timeout = args["timeout"].as_u64().unwrap_or(300);
86
87 match check_command(cmd) {
89 DangerLevel::Dangerous(reason) => {
90 if !ctx.approve(&format!("⚠️ DANGEROUS: {reason}\nCommand: {cmd}")) {
91 return Ok(format!("Command blocked: {reason}. User denied."));
92 }
93 }
94 DangerLevel::Warn(reason) => {
95 if !ctx.approve(&format!("⚠ Warning: {reason}\nCommand: {cmd}")) {
96 return Ok(format!("Command skipped: {reason}. User denied."));
97 }
98 }
99 DangerLevel::Safe => {}
100 }
101
102 if std::env::var("AEGIS_SNAPSHOT").is_ok() && is_risky_for_snapshot(cmd) {
105 if let Ok(exe) = std::env::var("AEGIS_EXE") {
106 let _ = std::process::Command::new(&exe)
107 .arg("__snapshot-cwd")
108 .arg(&ctx.session_id)
109 .arg(cmd)
110 .current_dir(&ctx.cwd)
111 .status();
112 }
113 }
114
115 let mut command = tokio::process::Command::new("sh");
116 command
117 .arg("-c")
118 .arg(cmd)
119 .current_dir(&ctx.cwd)
120 .env("AEGIS_SESSION", &ctx.session_id)
121 .stdout(Stdio::piped())
122 .stderr(Stdio::piped())
123 .kill_on_drop(true);
124 #[cfg(unix)]
127 command.process_group(0);
128
129 #[cfg(target_os = "linux")]
134 {
135 if ctx.sandbox_enabled {
136 let identity = ctx.effective_identity();
137 let policy = aegis_security::derive_sandbox_policy(&identity, "terminal", &ctx.cwd);
138 if policy.deny_all {
139 return Ok(format!(
140 "Command denied by sandbox policy: identity '{}' with trust level '{}' is not authorized to run shell commands.",
141 identity.display(),
142 identity.trust_level(),
143 ));
144 }
145 let policy_for_hook = policy.clone();
146 unsafe {
151 command.pre_exec(move || {
152 aegis_sandbox::apply_policy_pre_exec(&policy_for_hook)
153 });
154 }
155 tracing::debug!(
156 identity = %identity.display(),
157 trust = %identity.trust_level(),
158 "terminal: applying sandbox policy"
159 );
160 }
161 }
162
163 let child = command
164 .spawn()
165 .map_err(|e| anyhow::anyhow!("Failed to execute: {e}"))?;
166 let pid = child.id();
171 let mut guard = ChildKillGuard { pid };
172 let output = match tokio::time::timeout(
173 std::time::Duration::from_secs(timeout),
174 child.wait_with_output(),
175 )
176 .await
177 {
178 Ok(r) => {
179 guard.disarm(); r.map_err(|e| anyhow::anyhow!("Failed to execute: {e}"))?
181 }
182 Err(_) => {
183 kill_process_group(pid);
185 guard.disarm();
186 return Err(anyhow::anyhow!("Command timed out after {timeout}s"));
187 }
188 };
189
190 let stdout = String::from_utf8_lossy(&output.stdout);
191 let stderr = String::from_utf8_lossy(&output.stderr);
192 let code = output.status.code().unwrap_or(-1);
193
194 let mut result = String::new();
195 if !stdout.is_empty() {
196 result.push_str(&stdout);
197 }
198 if !stderr.is_empty() {
199 if !result.is_empty() {
200 result.push('\n');
201 }
202 result.push_str("[stderr]\n");
203 result.push_str(&stderr);
204 }
205 if code != 0 {
206 result.push_str(&format!("\n[exit code: {code}]"));
207 }
208
209 if result.len() > 50_000 {
212 let lines: Vec<&str> = result.lines().collect();
213 if lines.len() > 100 {
214 let head: String = lines[..20].join("\n");
215 let tail: String = lines[lines.len() - 80..].join("\n");
216 let omitted = lines.len() - 100;
217 result = format!("{head}\n\n... [{omitted} lines omitted] ...\n\n{tail}");
218 } else {
219 result.truncate(result.floor_char_boundary(50_000));
220 result.push_str("\n... [output truncated at 50KB]");
221 }
222 }
223
224 Ok(sanitize_credentials(&result))
225 }
226}
227
228pub struct ReadFileTool;
233
234#[async_trait]
235impl Tool for ReadFileTool {
236 fn name(&self) -> &str {
237 "read_file"
238 }
239 fn description(&self) -> &str {
240 "Read file contents with line numbers. Supports offset/limit for large files."
241 }
242 fn parameters(&self) -> Value {
243 json!({
244 "type": "object",
245 "properties": {
246 "path": { "type": "string", "description": "File path (relative to CWD)" },
247 "offset": { "type": "integer", "description": "Start line (0-based, default 0)" },
248 "limit": { "type": "integer", "description": "Max lines to read (default 2000)" }
249 },
250 "required": ["path"]
251 })
252 }
253 async fn execute(&self, args: Value, ctx: &ToolContext<'_>) -> Result<String> {
254 let path_str = args["path"].as_str().unwrap_or("");
255 let offset = args["offset"].as_u64().unwrap_or(0) as usize;
256 let limit = args["limit"].as_u64().unwrap_or(2000) as usize;
257
258 let safe_path = check_path(path_str, &ctx.cwd)?;
259
260 if !safe_path.exists() {
261 if let Some(parent) = safe_path.parent() {
263 if parent.is_dir() {
264 let fname = safe_path.file_name().unwrap_or_default().to_string_lossy();
265 let suggestions: Vec<String> = std::fs::read_dir(parent)?
266 .filter_map(|e| e.ok())
267 .map(|e| e.file_name().to_string_lossy().to_string())
268 .filter(|n| strsim::jaro_winkler(n, &fname) > 0.7)
269 .take(3)
270 .collect();
271 if !suggestions.is_empty() {
272 return Ok(format!(
273 "File not found: {path_str}\nDid you mean: {}?",
274 suggestions.join(", ")
275 ));
276 }
277 }
278 }
279 anyhow::bail!("File not found: {path_str}");
280 }
281
282 let preview = std::fs::read(safe_path.as_path())?;
284 if preview.len() >= 512 && preview[..512].iter().filter(|&&b| b == 0).count() > 4 {
285 let ext = safe_path.extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase();
287 match ext.as_str() {
288 "png" | "jpg" | "jpeg" | "gif" | "webp" => {
289 let data = base64::Engine::encode(
290 &base64::engine::general_purpose::STANDARD,
291 &preview,
292 );
293 let mime = match ext.as_str() {
294 "png" => "image/png",
295 "jpg" | "jpeg" => "image/jpeg",
296 "gif" => "image/gif",
297 "webp" => "image/webp",
298 _ => "image/png",
299 };
300 return Ok(format!("[IMAGE:base64:{mime}:{data}]"));
301 }
302 "pdf" => {
303 let data = base64::Engine::encode(
304 &base64::engine::general_purpose::STANDARD,
305 &preview,
306 );
307 return Ok(format!("[DOCUMENT:base64:application/pdf:{data}]"));
308 }
309 _ => {
310 return Ok(format!(
311 "Binary file ({} bytes): {path_str}. Supported formats for viewing: png, jpg, gif, webp, pdf.",
312 preview.len()
313 ));
314 }
315 }
316 }
317
318 let content = tokio::fs::read_to_string(&safe_path)
319 .await
320 .map_err(|e| anyhow::anyhow!("Cannot read {path_str}: {e}"))?;
321
322 let lines: Vec<&str> = content.lines().collect();
323 let total = lines.len();
324 let end = (offset + limit).min(total);
325 let slice = &lines[offset.min(total)..end];
326
327 let width = format!("{}", end).len();
328 let numbered: String = slice
329 .iter()
330 .enumerate()
331 .map(|(i, line)| format!("{:>width$} │ {}\n", offset + i + 1, line, width = width))
332 .collect();
333
334 let mut result = numbered;
335 if end < total {
336 result.push_str(&format!(
337 "\n... ({} more lines, use offset={} to continue)",
338 total - end,
339 end
340 ));
341 }
342 Ok(result)
343 }
344}
345
346pub struct WriteFileTool;
351
352#[async_trait]
353impl Tool for WriteFileTool {
354 fn name(&self) -> &str {
355 "write_file"
356 }
357 fn description(&self) -> &str {
358 "Write content to a file. Creates parent directories automatically."
359 }
360 fn parameters(&self) -> Value {
361 json!({
362 "type": "object",
363 "properties": {
364 "path": { "type": "string", "description": "File path" },
365 "content": { "type": "string", "description": "File content to write" }
366 },
367 "required": ["path", "content"]
368 })
369 }
370 async fn execute(&self, args: Value, ctx: &ToolContext<'_>) -> Result<String> {
371 let path_str = args["path"].as_str().unwrap_or("");
372 let content = args["content"].as_str().unwrap_or("");
373
374 let safe_path = check_path(path_str, &ctx.cwd)?;
375
376 let fname = safe_path.file_name().unwrap_or_default().to_string_lossy();
378 let sensitive = [".env", "id_rsa", "id_ed25519", ".pem", ".key"];
379 if sensitive.iter().any(|s| fname.contains(s))
380 && !ctx.approve(&format!("Writing to sensitive file: {path_str}"))
381 {
382 return Ok("Write cancelled: sensitive file.".to_string());
383 }
384
385 if let Some(parent) = safe_path.parent() {
386 tokio::fs::create_dir_all(parent).await?;
387 }
388
389 if safe_path.exists() {
391 let _ = crate::tools::CheckpointManager::backup(&safe_path);
392 }
393
394 tokio::fs::write(&safe_path, content).await?;
395 let lines = content.lines().count();
396 Ok(format!("Wrote {} lines to {path_str}", lines))
397 }
398}
399
400pub struct PatchTool;
405
406#[async_trait]
407impl Tool for PatchTool {
408 fn name(&self) -> &str {
409 "patch"
410 }
411 fn description(&self) -> &str {
412 "Replace text in a file. Finds old_string and replaces with new_string."
413 }
414 fn parameters(&self) -> Value {
415 json!({
416 "type": "object",
417 "properties": {
418 "path": { "type": "string", "description": "File path" },
419 "old_string": { "type": "string", "description": "Text to find" },
420 "new_string": { "type": "string", "description": "Replacement text" }
421 },
422 "required": ["path", "old_string", "new_string"]
423 })
424 }
425 async fn execute(&self, args: Value, ctx: &ToolContext<'_>) -> Result<String> {
426 let path_str = args["path"].as_str().unwrap_or("");
427 let old = args["old_string"].as_str().unwrap_or("");
428 let new = args["new_string"].as_str().unwrap_or("");
429
430 let safe_path = check_path(path_str, &ctx.cwd)?;
431 let content = tokio::fs::read_to_string(&safe_path)
432 .await
433 .map_err(|e| anyhow::anyhow!("Cannot read {path_str}: {e}"))?;
434
435 if content.contains(old) {
437 let _ = CheckpointManager::backup(&safe_path);
438 let updated = content.replacen(old, new, 1);
439 tokio::fs::write(&safe_path, &updated).await?;
440 return Ok(format!("Patched {path_str}: replaced 1 occurrence."));
441 }
442
443 let old_trimmed: Vec<&str> = old.lines().map(|l| l.trim()).collect();
445 let content_lines: Vec<&str> = content.lines().collect();
446
447 if !old_trimmed.is_empty() {
448 for start in 0..content_lines.len() {
449 let end = start + old_trimmed.len();
450 if end > content_lines.len() {
451 break;
452 }
453 let window: Vec<&str> =
454 content_lines[start..end].iter().map(|l| l.trim()).collect();
455 if window == old_trimmed {
456 let mut result_lines: Vec<String> = content_lines[..start]
457 .iter()
458 .map(|s| s.to_string())
459 .collect();
460 for line in new.lines() {
461 result_lines.push(line.to_string());
462 }
463 result_lines.extend(content_lines[end..].iter().map(|s| s.to_string()));
464 let updated = result_lines.join("\n");
465 tokio::fs::write(&safe_path, &updated).await?;
466 return Ok(format!(
467 "Patched {path_str}: fuzzy match at line {} (whitespace-insensitive).",
468 start + 1
469 ));
470 }
471 }
472 }
473
474 Ok(format!(
475 "No match found in {path_str} for the given old_string."
476 ))
477 }
478}
479
480pub struct SearchFilesTool;
485
486#[async_trait]
487impl Tool for SearchFilesTool {
488 fn name(&self) -> &str {
489 "search_files"
490 }
491 fn description(&self) -> &str {
492 "Search for text patterns in files. Uses ripgrep if available, falls back to built-in regex."
493 }
494 fn parameters(&self) -> Value {
495 json!({
496 "type": "object",
497 "properties": {
498 "pattern": { "type": "string", "description": "Regex pattern to search for" },
499 "path": { "type": "string", "description": "Directory to search in (default: CWD)" },
500 "include": { "type": "string", "description": "File glob filter, e.g. '*.rs'" },
501 "limit": { "type": "integer", "description": "Max results (default 50)" }
502 },
503 "required": ["pattern"]
504 })
505 }
506 async fn execute(&self, args: Value, ctx: &ToolContext<'_>) -> Result<String> {
507 let pattern = args["pattern"].as_str().unwrap_or("");
508 let search_path = args["path"].as_str().unwrap_or(".");
509 let include = args["include"].as_str();
510 let limit = args["limit"].as_u64().unwrap_or(50);
511
512 let dir = check_path(search_path, &ctx.cwd)?;
513
514 if which_rg() {
516 return rg_search(pattern, &dir, include, limit).await;
517 }
518
519 builtin_search(pattern, &dir, include, limit).await
521 }
522}
523
524fn which_rg() -> bool {
525 std::process::Command::new("rg")
526 .arg("--version")
527 .output()
528 .is_ok()
529}
530
531async fn rg_search(
532 pattern: &str,
533 dir: &std::path::Path,
534 include: Option<&str>,
535 limit: u64,
536) -> Result<String> {
537 let mut cmd = tokio::process::Command::new("rg");
538 cmd.arg("--line-number")
539 .arg("--no-heading")
540 .arg("--color=never")
541 .arg("--max-count")
542 .arg(limit.to_string());
543 if let Some(glob) = include {
544 cmd.arg("--glob").arg(glob);
545 }
546 cmd.arg(pattern)
547 .arg(dir)
548 .stdout(Stdio::piped())
549 .stderr(Stdio::piped());
550
551 let output = tokio::time::timeout(std::time::Duration::from_secs(30), cmd.output())
552 .await
553 .map_err(|_| anyhow::anyhow!("search timed out"))??;
554
555 let result = String::from_utf8_lossy(&output.stdout);
556 if result.is_empty() {
557 Ok("No matches found.".to_string())
558 } else {
559 Ok(truncate_output(&result, 50_000))
560 }
561}
562
563async fn builtin_search(
564 pattern: &str,
565 dir: &std::path::Path,
566 include: Option<&str>,
567 limit: u64,
568) -> Result<String> {
569 let re = regex::Regex::new(pattern).map_err(|e| anyhow::anyhow!("Invalid regex: {e}"))?;
570 let glob_re = include.and_then(|g| {
571 let g = g.replace('.', r"\.").replace('*', ".*").replace('?', ".");
572 regex::Regex::new(&format!("^{g}$")).ok()
573 });
574
575 let mut results = Vec::new();
576 let mut stack = vec![dir.to_path_buf()];
577
578 while let Some(d) = stack.pop() {
579 let entries = match std::fs::read_dir(&d) {
580 Ok(e) => e,
581 Err(_) => continue,
582 };
583 for entry in entries.flatten() {
584 let path = entry.path();
585 if path.is_dir() {
586 let name = path.file_name().unwrap_or_default().to_string_lossy();
587 if !name.starts_with('.') && name != "node_modules" && name != "target" {
588 stack.push(path);
589 }
590 } else if path.is_file() {
591 if let Some(ref gre) = glob_re {
592 let fname = path.file_name().unwrap_or_default().to_string_lossy();
593 if !gre.is_match(&fname) {
594 continue;
595 }
596 }
597 if let Ok(content) = std::fs::read_to_string(&path) {
598 for (i, line) in content.lines().enumerate() {
599 if re.is_match(line) {
600 let rel = path.strip_prefix(dir).unwrap_or(&path);
601 results.push(format!("{}:{}:{}", rel.display(), i + 1, line));
602 if results.len() as u64 >= limit {
603 break;
604 }
605 }
606 }
607 }
608 if results.len() as u64 >= limit {
609 break;
610 }
611 }
612 }
613 if results.len() as u64 >= limit {
614 break;
615 }
616 }
617
618 if results.is_empty() {
619 Ok("No matches found.".to_string())
620 } else {
621 Ok(results.join("\n"))
622 }
623}
624
625fn truncate_output(s: &str, max: usize) -> String {
626 if s.len() <= max {
627 return s.to_string();
628 }
629 let lines: Vec<&str> = s.lines().collect();
632 if lines.len() > 100 {
633 let head_n = 20;
634 let tail_n = 80;
635 let head: String = lines[..head_n].join("\n");
636 let tail: String = lines[lines.len() - tail_n..].join("\n");
637 let omitted = lines.len() - head_n - tail_n;
638 return format!("{head}\n\n... [{omitted} lines omitted] ...\n\n{tail}");
639 }
640 let mut t = s[..s.floor_char_boundary(max)].to_string();
642 t.push_str("\n... [truncated]");
643 t
644}
645
646pub struct CheckpointManager;
651
652impl CheckpointManager {
653 fn checkpoint_dir() -> std::path::PathBuf {
654 let dir = aegis_types::paths::config_dir().join("checkpoints");
655 let _ = std::fs::create_dir_all(&dir);
656 dir
657 }
658
659 pub fn backup(path: &std::path::Path) -> Result<()> {
661 if !path.exists() {
662 return Ok(());
663 }
664 let ts = chrono::Utc::now().format("%Y%m%d-%H%M%S").to_string();
665 let cp_dir = Self::checkpoint_dir().join(&ts);
666 std::fs::create_dir_all(&cp_dir)?;
667
668 let fname = path.file_name().unwrap_or_default();
670 std::fs::copy(path, cp_dir.join(fname))?;
671
672 std::fs::write(cp_dir.join(".original_path"), path.display().to_string())?;
674
675 Self::prune_old(20)?;
677 Ok(())
678 }
679
680 pub fn list() -> Result<Vec<(String, String)>> {
682 let dir = Self::checkpoint_dir();
683 let mut entries: Vec<(String, String)> = Vec::new();
684 if let Ok(rd) = std::fs::read_dir(&dir) {
685 for entry in rd.flatten() {
686 let name = entry.file_name().to_string_lossy().to_string();
687 let orig = std::fs::read_to_string(entry.path().join(".original_path"))
688 .unwrap_or_default();
689 entries.push((name, orig));
690 }
691 }
692 entries.sort_by(|a, b| b.0.cmp(&a.0));
693 Ok(entries)
694 }
695
696 pub fn restore(checkpoint_name: &str) -> Result<String> {
698 let cp_dir = Self::checkpoint_dir().join(checkpoint_name);
699 if !cp_dir.exists() {
700 anyhow::bail!("Checkpoint not found: {checkpoint_name}");
701 }
702 let orig_path = std::fs::read_to_string(cp_dir.join(".original_path"))?;
703 for entry in std::fs::read_dir(&cp_dir)?.flatten() {
705 let fname = entry.file_name().to_string_lossy().to_string();
706 if fname == ".original_path" {
707 continue;
708 }
709 std::fs::copy(entry.path(), &orig_path)?;
710 return Ok(format!(
711 "Restored {} from checkpoint {checkpoint_name}",
712 orig_path
713 ));
714 }
715 anyhow::bail!("No file found in checkpoint {checkpoint_name}")
716 }
717
718 fn prune_old(max: usize) -> Result<()> {
719 let dir = Self::checkpoint_dir();
720 let mut entries: Vec<_> = std::fs::read_dir(&dir)?
721 .flatten()
722 .filter(|e| e.path().is_dir())
723 .collect();
724 entries.sort_by_key(|e| e.file_name());
725 while entries.len() > max {
726 if let Some(oldest) = entries.first() {
727 let _ = std::fs::remove_dir_all(oldest.path());
728 }
729 entries.remove(0);
730 }
731 Ok(())
732 }
733}
734
735pub fn todo_path(session_id: &str) -> std::path::PathBuf {
741 aegis_types::paths::config_dir()
742 .join("todos")
743 .join(format!("{session_id}.txt"))
744}
745
746pub fn read_todo_progress(session_id: &str) -> Option<(usize, usize, String)> {
750 let content = std::fs::read_to_string(todo_path(session_id)).ok()?;
751 let mut total = 0usize;
752 let mut done = 0usize;
753 let mut current = String::new();
754 for line in content.lines() {
755 if line.trim().is_empty() {
756 continue;
757 }
758 total += 1;
759 if line.starts_with("[x] ") {
760 done += 1;
761 } else if current.is_empty() {
762 current = line.strip_prefix("[ ] ").unwrap_or(line).to_string();
763 }
764 }
765 if total == 0 {
766 None
767 } else {
768 Some((done, total, current))
769 }
770}
771
772pub struct TodoTool;
773
774#[async_trait]
775impl Tool for TodoTool {
776 fn name(&self) -> &str {
777 "todo"
778 }
779 fn description(&self) -> &str {
780 "Track a multi-step task as a checklist for this session, shown to the user \
781 as a live progress bar. Use it to plan and drive long tasks: add each step \
782 up front, then mark steps complete as you finish them. If an earlier step \
783 turns out wrong, use `rollback` to reopen it and everything after it (the \
784 progress bar moves back). Actions: add (task), complete (index, 1-based), \
785 reopen (index), rollback (index = first step to redo), list."
786 }
787 fn parameters(&self) -> Value {
788 json!({
789 "type": "object",
790 "properties": {
791 "action": { "type": "string", "enum": ["add", "complete", "reopen", "rollback", "list"], "description": "Action to perform" },
792 "task": { "type": "string", "description": "Task description (for add)" },
793 "index": { "type": "integer", "description": "1-based task index. complete/reopen: that task; rollback: that task and all after it are reopened" }
794 },
795 "required": ["action"]
796 })
797 }
798 async fn execute(&self, args: Value, ctx: &ToolContext<'_>) -> Result<String> {
799 let todo_path = todo_path(&ctx.session_id);
801 let _ = std::fs::create_dir_all(todo_path.parent().expect("todo path has parent"));
802
803 let mut tasks: Vec<(bool, String)> = if todo_path.exists() {
804 std::fs::read_to_string(&todo_path)?
805 .lines()
806 .map(|l| {
807 if let Some(rest) = l.strip_prefix("[x] ") {
808 (true, rest.to_string())
809 } else if let Some(rest) = l.strip_prefix("[ ] ") {
810 (false, rest.to_string())
811 } else {
812 (false, l.to_string())
813 }
814 })
815 .collect()
816 } else {
817 Vec::new()
818 };
819
820 match args["action"].as_str().unwrap_or("list") {
821 "add" => {
822 let task = args["task"].as_str().unwrap_or("(no description)");
823 tasks.push((false, task.to_string()));
824 save_todos(&todo_path, &tasks)?;
825 Ok(format!("Added task #{}: {task}", tasks.len()))
826 }
827 "complete" => {
828 let idx = args["index"].as_u64().unwrap_or(0) as usize;
829 if idx == 0 || idx > tasks.len() {
830 return Ok("Invalid task index.".to_string());
831 }
832 tasks[idx - 1].0 = true;
833 save_todos(&todo_path, &tasks)?;
834 Ok(format!("Completed task #{}: {}", idx, tasks[idx - 1].1))
835 }
836 "reopen" => {
837 let idx = args["index"].as_u64().unwrap_or(0) as usize;
838 if idx == 0 || idx > tasks.len() {
839 return Ok("Invalid task index.".to_string());
840 }
841 tasks[idx - 1].0 = false;
842 save_todos(&todo_path, &tasks)?;
843 Ok(format!("Reopened task #{}: {}", idx, tasks[idx - 1].1))
844 }
845 "rollback" => {
846 let idx = args["index"].as_u64().unwrap_or(0) as usize;
849 if idx == 0 || idx > tasks.len() {
850 return Ok("Invalid task index.".to_string());
851 }
852 for t in tasks.iter_mut().skip(idx - 1) {
853 t.0 = false;
854 }
855 save_todos(&todo_path, &tasks)?;
856 let done = tasks.iter().filter(|(d, _)| *d).count();
857 Ok(format!(
858 "Rolled back to task #{} ({}). Progress now {}/{}; tasks #{}–#{} reopened.",
859 idx,
860 tasks[idx - 1].1,
861 done,
862 tasks.len(),
863 idx,
864 tasks.len()
865 ))
866 }
867 _ => {
868 if tasks.is_empty() {
869 return Ok("No tasks.".to_string());
870 }
871 let list: String = tasks
872 .iter()
873 .enumerate()
874 .map(|(i, (done, desc))| {
875 let mark = if *done { "x" } else { " " };
876 format!(" {}. [{}] {}", i + 1, mark, desc)
877 })
878 .collect::<Vec<_>>()
879 .join("\n");
880 Ok(list)
881 }
882 }
883 }
884}
885
886fn save_todos(path: &std::path::Path, tasks: &[(bool, String)]) -> Result<()> {
887 let content: String = tasks
888 .iter()
889 .map(|(done, desc)| {
890 if *done {
891 format!("[x] {desc}")
892 } else {
893 format!("[ ] {desc}")
894 }
895 })
896 .collect::<Vec<_>>()
897 .join("\n");
898 std::fs::write(path, content)?;
899 Ok(())
900}
901
902struct BgTask {
907 child: tokio::process::Child,
908 log: std::path::PathBuf,
909 cmd: String,
910 started: std::time::Instant,
911}
912
913pub struct BackgroundTool {
917 dir: std::path::PathBuf,
918 tasks: Arc<std::sync::Mutex<std::collections::HashMap<String, BgTask>>>,
919 backend: BgBackend,
920}
921
922impl BackgroundTool {
923 pub fn new() -> Self {
925 let dir = aegis_types::paths::config_dir().join("bg");
926 Self {
927 dir,
928 tasks: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
929 backend: BgBackend::Auto,
930 }
931 }
932
933 pub fn with_backend(mut self, backend: BgBackend) -> Self {
935 self.backend = backend;
936 self
937 }
938}
939
940#[derive(Debug, Clone, Copy, PartialEq, Eq)]
944pub enum BgBackend {
945 Auto,
946 Tmux,
947 Child,
948}
949
950impl BgBackend {
951 pub fn from_config(s: &str) -> Self {
953 match s.trim().to_ascii_lowercase().as_str() {
954 "tmux" => BgBackend::Tmux,
955 "child" => BgBackend::Child,
956 _ => BgBackend::Auto,
957 }
958 }
959
960 fn effective(self) -> BgBackend {
962 match self {
963 BgBackend::Auto => {
964 if tmux_available() {
965 BgBackend::Tmux
966 } else {
967 BgBackend::Child
968 }
969 }
970 other => other,
971 }
972 }
973}
974
975fn tmux_available() -> bool {
977 std::process::Command::new("tmux")
978 .arg("-V")
979 .stdout(Stdio::null())
980 .stderr(Stdio::null())
981 .status()
982 .map(|s| s.success())
983 .unwrap_or(false)
984}
985
986fn tmux_session_name(id: &str) -> String {
989 let safe: String = id
990 .chars()
991 .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
992 .collect();
993 format!("aegis-{safe}")
994}
995
996fn posix_squote(s: &str) -> String {
998 format!("'{}'", s.replace('\'', "'\\''"))
999}
1000
1001impl Default for BackgroundTool {
1002 fn default() -> Self {
1003 Self::new()
1004 }
1005}
1006
1007fn tail_file(path: &std::path::Path, lines: usize) -> String {
1009 use std::io::{Read, Seek, SeekFrom};
1010 let mut f = match std::fs::File::open(path) {
1011 Ok(f) => f,
1012 Err(_) => return String::new(),
1013 };
1014 let len = f.metadata().map(|m| m.len()).unwrap_or(0);
1015 const CAP: u64 = 64 * 1024;
1016 let _ = f.seek(SeekFrom::Start(len.saturating_sub(CAP)));
1017 let mut raw = Vec::new();
1018 let _ = f.read_to_end(&mut raw);
1019 let text = String::from_utf8_lossy(&raw);
1020 let all: Vec<&str> = text.lines().collect();
1021 let start = all.len().saturating_sub(lines);
1022 all[start..].join("\n")
1023}
1024
1025fn write_bg_meta(dir: &std::path::Path, id: &str, pid: u32, cmd: &str) {
1029 write_bg_meta_full(dir, id, pid, cmd, "child", "");
1030}
1031
1032fn write_bg_meta_full(dir: &std::path::Path, id: &str, pid: u32, cmd: &str, backend: &str, session: &str) {
1033 let meta = json!({ "pid": pid, "cmd": cmd, "backend": backend, "session": session, "started": now_epoch() });
1034 let _ = std::fs::write(dir.join(format!("{id}.meta.json")), meta.to_string());
1035}
1036
1037fn now_epoch() -> u64 {
1039 std::time::SystemTime::now()
1040 .duration_since(std::time::UNIX_EPOCH)
1041 .map(|d| d.as_secs())
1042 .unwrap_or(0)
1043}
1044
1045fn read_bg_started(dir: &std::path::Path, id: &str) -> Option<u64> {
1047 let content = std::fs::read_to_string(dir.join(format!("{id}.meta.json"))).ok()?;
1048 let v: Value = serde_json::from_str(&content).ok()?;
1049 v["started"].as_u64().filter(|s| *s > 0)
1050}
1051
1052fn human_dur(secs: u64) -> String {
1054 if secs < 60 {
1055 format!("{secs}s")
1056 } else if secs < 3600 {
1057 format!("{}m{}s", secs / 60, secs % 60)
1058 } else {
1059 format!("{}h{}m", secs / 3600, (secs % 3600) / 60)
1060 }
1061}
1062
1063fn bg_elapsed_suffix(dir: &std::path::Path, id: &str) -> String {
1065 match read_bg_started(dir, id) {
1066 Some(started) => format!(" (ran {})", human_dur(now_epoch().saturating_sub(started))),
1067 None => String::new(),
1068 }
1069}
1070
1071fn read_bg_meta(dir: &std::path::Path, id: &str) -> Option<(u32, String)> {
1073 let content = std::fs::read_to_string(dir.join(format!("{id}.meta.json"))).ok()?;
1074 let v: Value = serde_json::from_str(&content).ok()?;
1075 let pid = v["pid"].as_u64()? as u32;
1076 let cmd = v["cmd"].as_str().unwrap_or("").to_string();
1077 Some((pid, cmd))
1078}
1079
1080fn read_bg_session(dir: &std::path::Path, id: &str) -> Option<String> {
1082 let content = std::fs::read_to_string(dir.join(format!("{id}.meta.json"))).ok()?;
1083 let v: Value = serde_json::from_str(&content).ok()?;
1084 if v["backend"].as_str() == Some("tmux") {
1085 v["session"].as_str().filter(|s| !s.is_empty()).map(|s| s.to_string())
1086 } else {
1087 None
1088 }
1089}
1090
1091fn tmux_session_alive(session: &str) -> bool {
1093 std::process::Command::new("tmux")
1094 .args(["has-session", "-t", session])
1095 .stdout(Stdio::null())
1096 .stderr(Stdio::null())
1097 .status()
1098 .map(|s| s.success())
1099 .unwrap_or(false)
1100}
1101
1102fn list_bg_meta(dir: &std::path::Path) -> Vec<(String, u32, String)> {
1104 let mut out = Vec::new();
1105 if let Ok(entries) = std::fs::read_dir(dir) {
1106 for e in entries.flatten() {
1107 if let Some(fname) = e.file_name().to_str() {
1108 if let Some(id) = fname.strip_suffix(".meta.json") {
1109 if let Some((pid, cmd)) = read_bg_meta(dir, id) {
1110 out.push((id.to_string(), pid, cmd));
1111 }
1112 }
1113 }
1114 }
1115 }
1116 out
1117}
1118
1119fn pid_alive(pid: u32) -> bool {
1121 pid != 0 && std::path::Path::new(&format!("/proc/{pid}")).exists()
1122}
1123
1124#[async_trait]
1125impl Tool for BackgroundTool {
1126 fn name(&self) -> &str {
1127 "background"
1128 }
1129 fn description(&self) -> &str {
1130 "Run and supervise long-running processes WITHOUT blocking. Use this for \
1131 anything that takes minutes/hours (builds, training, servers, data \
1132 pipelines): start the command in the background, then keep working and \
1133 periodically check it with status/logs, and kill it when done. Far better \
1134 than a blocking `terminal` call for long jobs. Jobs survive an aegis \
1135 restart — status/logs/kill still work in a later session (great for \
1136 resume/watchdog). Actions: start (command, optional id), status (id), \
1137 logs (id, lines), kill (id), list."
1138 }
1139 fn parameters(&self) -> Value {
1140 json!({
1141 "type": "object",
1142 "properties": {
1143 "action": { "type": "string", "enum": ["start", "status", "logs", "kill", "list"] },
1144 "command": { "type": "string", "description": "Shell command to run (action=start)" },
1145 "id": { "type": "string", "description": "Task id for status/logs/kill (auto-generated on start if omitted)" },
1146 "lines": { "type": "integer", "description": "Tail this many log lines (action=logs, default 40)" }
1147 },
1148 "required": ["action"]
1149 })
1150 }
1151 async fn execute(&self, args: Value, ctx: &ToolContext<'_>) -> Result<String> {
1152 match args["action"].as_str().unwrap_or("list") {
1153 "start" => {
1154 let cmd = args["command"].as_str().unwrap_or("").trim().to_string();
1155 if cmd.is_empty() {
1156 return Ok("Error: 'command' is required for action=start".to_string());
1157 }
1158 match check_command(&cmd) {
1159 DangerLevel::Dangerous(reason) => {
1160 if !ctx.approve(&format!("⚠️ DANGEROUS (background): {reason}\nCommand: {cmd}")) {
1161 return Ok(format!("Command blocked: {reason}. User denied."));
1162 }
1163 }
1164 DangerLevel::Warn(reason) => {
1165 if !ctx.approve(&format!("⚠ Warning (background): {reason}\nCommand: {cmd}")) {
1166 return Ok(format!("Command skipped: {reason}. User denied."));
1167 }
1168 }
1169 DangerLevel::Safe => {}
1170 }
1171 std::fs::create_dir_all(&self.dir).ok();
1172 let id = args["id"].as_str().map(|s| s.to_string()).unwrap_or_else(|| {
1173 let nanos = std::time::SystemTime::now()
1174 .duration_since(std::time::UNIX_EPOCH)
1175 .map(|d| d.as_nanos() as u64)
1176 .unwrap_or(0);
1177 format!("bg-{:06x}", nanos & 0xff_ffff)
1178 });
1179 let log = self.dir.join(format!("{id}.log"));
1180
1181 if self.backend.effective() == BgBackend::Tmux {
1186 if !tmux_available() {
1187 return Ok("Error: background_backend=tmux but tmux is not installed. Install tmux or set [tools] background_backend = \"child\".".to_string());
1188 }
1189 let session = tmux_session_name(&id);
1190 if tmux_session_alive(&session) {
1191 return Ok(format!("Error: tmux session '{session}' already exists (task id '{id}' in use)."));
1192 }
1193 let inner = format!("{cmd} 2>&1 | tee {}", posix_squote(&log.display().to_string()));
1194 let started = std::process::Command::new("tmux")
1195 .args(["new-session", "-d", "-s", &session, "-c"])
1196 .arg(&ctx.cwd)
1197 .arg("sh")
1198 .arg("-c")
1199 .arg(&inner)
1200 .status()
1201 .map(|s| s.success())
1202 .unwrap_or(false);
1203 if !started {
1204 return Ok(format!("Error: failed to start tmux session '{session}' for task '{id}'."));
1205 }
1206 let _ = std::process::Command::new("tmux")
1209 .args(["set-option", "-t", &session, "remain-on-exit", "on"])
1210 .status();
1211 write_bg_meta_full(&self.dir, &id, 0, &cmd, "tmux", &session);
1212 return Ok(format!(
1213 "Started background task '{id}' in tmux session '{session}'.\n\
1214 Attach (watch / type commands live): tmux attach -t {session}\n\
1215 Logs: {}\n\
1216 Check: background action=status id={id} • kill: background action=kill id={id}",
1217 log.display()
1218 ));
1219 }
1220
1221 let file = std::fs::File::create(&log)
1222 .map_err(|e| anyhow::anyhow!("create log file: {e}"))?;
1223 let file2 = file.try_clone().map_err(|e| anyhow::anyhow!("clone log file: {e}"))?;
1224 let child = tokio::process::Command::new("sh")
1225 .arg("-c")
1226 .arg(&cmd)
1227 .current_dir(&ctx.cwd)
1228 .stdout(Stdio::from(file))
1229 .stderr(Stdio::from(file2))
1230 .spawn()
1231 .map_err(|e| anyhow::anyhow!("spawn failed: {e}"))?;
1232 let pid = child.id().unwrap_or(0);
1233 if let Ok(mut tasks) = self.tasks.lock() {
1234 tasks.insert(
1235 id.clone(),
1236 BgTask {
1237 child,
1238 log: log.clone(),
1239 cmd: cmd.clone(),
1240 started: std::time::Instant::now(),
1241 },
1242 );
1243 }
1244 write_bg_meta(&self.dir, &id, pid, &cmd);
1247 Ok(format!(
1248 "Started background task '{id}' (pid {pid}).\nLogs: {}\nCheck with: background action=status id={id} • background action=logs id={id}",
1249 log.display()
1250 ))
1251 }
1252 "status" => {
1253 let id = args["id"].as_str().unwrap_or("");
1254 {
1256 let mut tasks = self.tasks.lock().map_err(|_| anyhow::anyhow!("lock poisoned"))?;
1257 if let Some(task) = tasks.get_mut(id) {
1258 let elapsed = task.started.elapsed().as_secs();
1259 return Ok(match task.child.try_wait() {
1260 Ok(Some(st)) => format!(
1261 "Task '{id}' EXITED (code {}) after {elapsed}s.\ncmd: {}",
1262 st.code().unwrap_or(-1),
1263 task.cmd
1264 ),
1265 Ok(None) => format!("Task '{id}' RUNNING ({elapsed}s).\ncmd: {}", task.cmd),
1266 Err(e) => format!("Task '{id}' status error: {e}"),
1267 });
1268 }
1269 }
1270 if let Some(session) = read_bg_session(&self.dir, id) {
1274 let state = if tmux_session_alive(&session) { "RUNNING" } else { "EXITED/gone" };
1275 let cmd = read_bg_meta(&self.dir, id).map(|(_, c)| c).unwrap_or_default();
1276 let elapsed = bg_elapsed_suffix(&self.dir, id);
1277 return Ok(format!(
1278 "Task '{id}' {state}{elapsed} (tmux session '{session}').\nAttach: tmux attach -t {session}\ncmd: {cmd}"
1279 ));
1280 }
1281 match read_bg_meta(&self.dir, id) {
1282 Some((pid, cmd)) => {
1283 let state = if pid_alive(pid) { "RUNNING" } else { "EXITED" };
1284 let elapsed = bg_elapsed_suffix(&self.dir, id);
1285 Ok(format!("Task '{id}' {state}{elapsed} (pid {pid}, from earlier session).\ncmd: {cmd}"))
1286 }
1287 None => Ok(format!("No background task '{id}'. Use action=list.")),
1288 }
1289 }
1290 "logs" => {
1291 let id = args["id"].as_str().unwrap_or("");
1292 let lines = args["lines"].as_u64().unwrap_or(40) as usize;
1293 let log = {
1295 let tasks = self.tasks.lock().map_err(|_| anyhow::anyhow!("lock poisoned"))?;
1296 tasks.get(id).map(|t| t.log.clone())
1297 }
1298 .unwrap_or_else(|| self.dir.join(format!("{id}.log")));
1299 if !log.exists() {
1300 return Ok(format!("No background task '{id}'. Use action=list."));
1301 }
1302 let out = tail_file(&log, lines);
1303 if out.is_empty() {
1304 Ok(format!("(no output yet for '{id}')"))
1305 } else {
1306 Ok(format!("--- {id} (last {lines} lines) ---\n{out}"))
1307 }
1308 }
1309 "kill" => {
1310 let id = args["id"].as_str().unwrap_or("");
1311 {
1313 let mut tasks = self.tasks.lock().map_err(|_| anyhow::anyhow!("lock poisoned"))?;
1314 if let Some(mut task) = tasks.remove(id) {
1315 let _ = task.child.start_kill();
1316 let _ = std::fs::remove_file(self.dir.join(format!("{id}.meta.json")));
1317 return Ok(format!("Killed background task '{id}'."));
1318 }
1319 }
1320 if let Some(session) = read_bg_session(&self.dir, id) {
1322 let _ = std::process::Command::new("tmux")
1323 .args(["kill-session", "-t", &session])
1324 .status();
1325 let _ = std::fs::remove_file(self.dir.join(format!("{id}.meta.json")));
1326 return Ok(format!("Killed background task '{id}' (tmux session '{session}')."));
1327 }
1328 match read_bg_meta(&self.dir, id) {
1329 Some((pid, _)) => {
1330 let _ = std::process::Command::new("kill").arg(pid.to_string()).status();
1331 let _ = std::fs::remove_file(self.dir.join(format!("{id}.meta.json")));
1332 Ok(format!("Sent kill to background task '{id}' (pid {pid})."))
1333 }
1334 None => Ok(format!("No background task '{id}'.")),
1335 }
1336 }
1337 _ => {
1338 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
1340 let mut out = String::from("Background tasks:\n");
1341 if let Ok(tasks) = self.tasks.lock() {
1342 for (id, task) in tasks.iter() {
1343 seen.insert(id.clone());
1344 out.push_str(&format!(
1345 " {id}: {}s (this session) {}\n",
1346 task.started.elapsed().as_secs(),
1347 task.cmd
1348 ));
1349 }
1350 }
1351 for (id, pid, cmd) in list_bg_meta(&self.dir) {
1352 if seen.contains(&id) {
1353 continue;
1354 }
1355 if let Some(session) = read_bg_session(&self.dir, &id) {
1356 let state = if tmux_session_alive(&session) { "RUNNING" } else { "exited" };
1357 out.push_str(&format!(" {id}: {state} (tmux {session}) {cmd}\n"));
1358 } else {
1359 let state = if pid_alive(pid) { "RUNNING" } else { "exited" };
1360 out.push_str(&format!(" {id}: {state} (pid {pid}) {cmd}\n"));
1361 }
1362 }
1363 if out == "Background tasks:\n" {
1364 return Ok("No background tasks.".to_string());
1365 }
1366 Ok(out)
1367 }
1368 }
1369 }
1370}
1371
1372pub struct ClarifyTool;
1377
1378#[async_trait]
1379impl Tool for ClarifyTool {
1380 fn name(&self) -> &str {
1381 "clarify"
1382 }
1383 fn description(&self) -> &str {
1384 "Ask the user a question and let them pick an answer. ALWAYS use this tool \
1385 (with concrete `options`) for any decision point — including confirming or \
1386 adjusting a plan, choosing a direction, or yes/no approvals — instead of \
1387 writing the choices in your prose reply and waiting for them to type. The \
1388 user picks with arrow keys; a 'manual input' choice is always added. Ask \
1389 several questions at once via `questions` (user switches with ←/→)."
1390 }
1391 fn parameters(&self) -> Value {
1392 json!({
1393 "type": "object",
1394 "properties": {
1395 "question": { "type": "string", "description": "The question to ask the user" },
1396 "options": {
1397 "type": "array",
1398 "items": { "type": "string" },
1399 "description": "Optional preset answers the user can choose from"
1400 },
1401 "questions": {
1402 "type": "array",
1403 "description": "Ask multiple questions at once; user switches with ←/→",
1404 "items": {
1405 "type": "object",
1406 "properties": {
1407 "question": { "type": "string" },
1408 "options": { "type": "array", "items": { "type": "string" } }
1409 },
1410 "required": ["question"]
1411 }
1412 }
1413 }
1414 })
1415 }
1416 async fn execute(&self, args: Value, _ctx: &ToolContext<'_>) -> Result<String> {
1417 let question = args["question"].as_str().unwrap_or("Could you clarify?");
1418 eprintln!("\n❓ {question}");
1419 eprint!("Your answer: ");
1420 let _ = std::io::Write::flush(&mut std::io::stderr());
1421 let mut answer = String::new();
1422 std::io::BufRead::read_line(&mut std::io::stdin().lock(), &mut answer)?;
1423 Ok(format!("User answered: {}", answer.trim()))
1424 }
1425}
1426
1427pub struct SessionSearchTool;
1432
1433#[async_trait]
1434impl Tool for SessionSearchTool {
1435 fn name(&self) -> &str {
1436 "session_search"
1437 }
1438 fn description(&self) -> &str {
1439 "Search past conversation sessions for relevant information using full-text search."
1440 }
1441 fn parameters(&self) -> Value {
1442 json!({
1443 "type": "object",
1444 "properties": {
1445 "query": { "type": "string", "description": "Search query" },
1446 "limit": { "type": "integer", "description": "Max results (default 5)" }
1447 },
1448 "required": ["query"]
1449 })
1450 }
1451 async fn execute(&self, args: Value, _ctx: &ToolContext<'_>) -> Result<String> {
1452 let query = args["query"].as_str().unwrap_or("");
1453 let limit = args["limit"].as_u64().unwrap_or(5) as u32;
1454
1455 let db_path = aegis_types::paths::config_dir().join("sessions.db");
1456 if !db_path.exists() {
1457 return Ok("No session history available.".to_string());
1458 }
1459
1460 let conn = rusqlite::Connection::open(&db_path)?;
1461 let sanitized = query
1462 .split_whitespace()
1463 .map(|w| {
1464 let c: String = w
1465 .chars()
1466 .filter(|c| c.is_alphanumeric() || *c == '_')
1467 .collect();
1468 format!("\"{c}\"")
1469 })
1470 .filter(|s| s != "\"\"")
1471 .collect::<Vec<_>>()
1472 .join(" ");
1473 if sanitized.is_empty() {
1474 return Ok("Empty query.".to_string());
1475 }
1476
1477 let mut stmt = conn.prepare(
1478 "SELECT m.session_id, m.role, snippet(messages_fts, 0, '>>>', '<<<', '...', 32)
1479 FROM messages_fts f JOIN messages m ON m.id = f.rowid
1480 WHERE messages_fts MATCH ?1 ORDER BY rank LIMIT ?2",
1481 )?;
1482 let results: Vec<String> = stmt
1483 .query_map(rusqlite::params![sanitized, limit], |row| {
1484 let sid: String = row.get(0)?;
1485 let role: String = row.get(1)?;
1486 let snip: String = row.get(2)?;
1487 Ok(format!(
1488 "[{}] ({}) {}",
1489 &sid[..sid.len().min(15)],
1490 role,
1491 snip
1492 ))
1493 })?
1494 .filter_map(|r| r.ok())
1495 .collect();
1496
1497 if results.is_empty() {
1498 Ok("No results found.".to_string())
1499 } else {
1500 Ok(results.join("\n"))
1501 }
1502 }
1503}
1504
1505pub struct SpawnTaskTool {
1510 semaphore: Arc<tokio::sync::Semaphore>,
1511}
1512
1513#[derive(Debug, Clone, PartialEq)]
1517struct TaskSpec {
1518 prompt: String,
1519 model: Option<String>,
1521 max_turns: u64,
1522}
1523
1524fn opt_str(v: &Value, key: &str) -> Option<String> {
1526 v[key]
1527 .as_str()
1528 .map(str::trim)
1529 .filter(|s| !s.is_empty())
1530 .map(str::to_string)
1531}
1532
1533fn parse_task_specs(args: &Value) -> Vec<TaskSpec> {
1542 let top_model = opt_str(args, "model");
1543 let top_max_turns = args["max_turns"].as_u64().unwrap_or(20);
1544
1545 if let Some(arr) = args["tasks"].as_array() {
1546 arr.iter()
1547 .filter_map(|t| {
1548 let prompt = opt_str(t, "prompt")?;
1549 Some(TaskSpec {
1550 prompt,
1551 model: opt_str(t, "model").or_else(|| top_model.clone()),
1552 max_turns: t["max_turns"].as_u64().unwrap_or(top_max_turns),
1553 })
1554 })
1555 .collect()
1556 } else if let Some(p) = args["prompt"].as_str() {
1557 vec![TaskSpec {
1558 prompt: p.to_string(),
1559 model: top_model,
1560 max_turns: top_max_turns,
1561 }]
1562 } else {
1563 Vec::new()
1564 }
1565}
1566
1567fn model_breakdown(specs: &[TaskSpec]) -> String {
1570 use std::collections::BTreeMap;
1571 let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
1572 for s in specs {
1573 *counts
1574 .entry(s.model.as_deref().unwrap_or("default model"))
1575 .or_insert(0) += 1;
1576 }
1577 counts
1578 .iter()
1579 .map(|(m, n)| format!("{n}×{m}"))
1580 .collect::<Vec<_>>()
1581 .join(", ")
1582}
1583
1584fn dispatch_header(specs: &[TaskSpec]) -> String {
1587 let n = specs.len();
1588 let noun = if n == 1 { "sub-agent" } else { "sub-agents" };
1589 format!(
1590 "🤖 Dispatched {n} {noun} in parallel (models: {})",
1591 model_breakdown(specs)
1592 )
1593}
1594
1595fn dispatch_footer(ok: usize, total: usize) -> String {
1597 let failed = total.saturating_sub(ok);
1598 format!("── {total} sub-agents · {ok} ✅ / {failed} ❌ ──")
1599}
1600
1601const MAX_WORKER_SUMMARY_CHARS: usize = 2000;
1607
1608fn cap_summary(text: &str, max: usize) -> String {
1612 if text.chars().count() <= max {
1613 return text.to_string();
1614 }
1615 let truncated: String = text.chars().take(max).collect();
1616 format!(
1617 "{truncated}\n…[truncated to {max} chars to protect the parent context; \
1618the full result is in this sub-agent's own session]"
1619 )
1620}
1621
1622impl SpawnTaskTool {
1623 pub fn new(max_concurrent: usize) -> Self { Self {
1625 semaphore: Arc::new(tokio::sync::Semaphore::new(max_concurrent)),
1626 }
1627 }
1628}
1629
1630#[async_trait]
1631impl Tool for SpawnTaskTool {
1632 fn name(&self) -> &str {
1633 "spawn_task"
1634 }
1635 fn description(&self) -> &str {
1636 "Spawn a sub-agent to handle a task independently. Supports parallel execution: pass `tasks` to fan out a dozen+ sub-agents at once (e.g. to investigate many leads in parallel). Each sub-task may run a DIFFERENT model via its `model` field — assign a strong model to the most suspicious lead and a cheap/fast model to bulk checks (heterogeneous dispatch). Each sub-agent returns ONLY a concise summary of its result (not its full transcript), so fanning out many sub-agents won't blow up your context. Set isolate=true for file-modifying tasks: each runs in its own git worktree+branch (no clobbering the main tree or sibling tasks; changes land on a branch for review)."
1637 }
1638 fn parameters(&self) -> Value {
1639 json!({
1640 "type": "object",
1641 "properties": {
1642 "prompt": { "type": "string", "description": "Task description for the worker (single-task mode)" },
1643 "model": { "type": "string", "description": "Model to run the sub-agent(s) with (e.g. gpt-4o, gpt-4o-mini, o1). Omit to use the configured default. For a `tasks` batch this is the fallback for items that don't set their own model." },
1644 "max_turns": { "type": "integer", "description": "Max iterations (default 20). For a `tasks` batch this is the fallback for items that don't set their own." },
1645 "isolate": { "type": "boolean", "description": "Run each sub-task in an isolated git worktree+branch (recommended when tasks modify files). Default false." },
1646 "tasks": {
1647 "type": "array",
1648 "items": { "type": "object", "properties": {
1649 "prompt": { "type": "string", "description": "Task description for this sub-agent" },
1650 "model": { "type": "string", "description": "Per-task model override for heterogeneous dispatch (falls back to top-level `model`, then the config default)" },
1651 "max_turns": { "type": "integer", "description": "Per-task iteration budget (falls back to top-level `max_turns`)" }
1652 }, "required": ["prompt"] },
1653 "description": "Multiple tasks to run in parallel. Each item may specify its own `model`/`max_turns` so different sub-agents can use different models."
1654 },
1655 "depends_on": {
1656 "type": "array",
1657 "items": { "type": "string" },
1658 "description": "List of task IDs that must complete before this task starts."
1659 },
1660 "timeout_secs": {
1661 "type": "integer",
1662 "description": "Timeout in seconds when waiting for depends_on tasks (default 300)"
1663 }
1664 },
1665 "required": []
1666 })
1667 }
1668 async fn execute(&self, args: Value, _ctx: &ToolContext<'_>) -> Result<String> {
1669 let depends_on: Vec<String> = args["depends_on"]
1671 .as_array()
1672 .map(|arr| {
1673 arr.iter()
1674 .filter_map(|v| v.as_str().map(String::from))
1675 .collect()
1676 })
1677 .unwrap_or_default();
1678
1679 if !depends_on.is_empty() {
1680 let timeout_secs = args["timeout_secs"].as_u64().unwrap_or(300);
1681 wait_for_tasks(depends_on, timeout_secs).await?;
1682 }
1683
1684 let specs = parse_task_specs(&args);
1686 if specs.is_empty() {
1687 return Ok("No prompt provided.".to_string());
1688 }
1689
1690 let isolate = args["isolate"].as_bool().unwrap_or(false);
1694 let repo = _ctx.cwd.clone();
1695 let mut results = Vec::new();
1696 let mut ok_count = 0usize;
1697
1698 let worker_bin = find_worker_binary();
1700
1701 let mut handles = Vec::new();
1702 for (i, spec) in specs.iter().enumerate() {
1703 let permit = self
1704 .semaphore
1705 .clone()
1706 .acquire_owned()
1707 .await
1708 .map_err(|e| anyhow::anyhow!("semaphore: {e}"))?;
1709 let bin = worker_bin.clone();
1710 let prompt = spec.prompt.clone();
1711 let model = spec.model.clone();
1712 let max_turns = spec.max_turns;
1713 let worktree = if isolate {
1714 create_worktree(&repo, &format!("{}-{}", std::process::id(), i))
1715 } else {
1716 None
1717 };
1718 let cwd = worktree.as_ref().map(|(p, _)| p.clone());
1719 let branch = worktree.as_ref().map(|(_, b)| b.clone());
1720
1721 let handle = tokio::spawn(async move {
1722 let _permit = permit;
1723 let label = model.clone().unwrap_or_default();
1724 let result = run_worker(&bin, &prompt, max_turns, model, cwd).await;
1725 (i, result, branch, label)
1726 });
1727 handles.push(handle);
1728 }
1729
1730 for handle in handles {
1731 let (i, result, branch, model_label) =
1732 handle.await.map_err(|e| anyhow::anyhow!("join: {e}"))?;
1733 let loc = branch
1734 .map(|b| format!(" [isolated → branch `{b}`; review/merge it]"))
1735 .unwrap_or_default();
1736 let model_tag = if model_label.is_empty() {
1737 String::new()
1738 } else {
1739 format!(" [model: {model_label}]")
1740 };
1741 match result {
1742 Ok(text) => {
1743 ok_count += 1;
1744 let summary = cap_summary(&text, MAX_WORKER_SUMMARY_CHARS);
1745 results.push(format!("[Worker {}] ✅{}{}\n{}", i + 1, model_tag, loc, summary))
1746 }
1747 Err(e) => results.push(format!("[Worker {}] ❌{}{} {}", i + 1, model_tag, loc, e)),
1748 }
1749 }
1750
1751 if specs.len() > 1 {
1753 let successful: Vec<&String> = results.iter()
1754 .filter(|r| r.contains('✅'))
1755 .collect();
1756 if successful.len() >= 2 {
1757 let strategies_dir = dirs_next::home_dir()
1758 .unwrap_or_default()
1759 .join(".aegis/strategies");
1760 let _ = std::fs::create_dir_all(&strategies_dir);
1761
1762 if let Ok(entries) = std::fs::read_dir(&strategies_dir) {
1764 let mut worker_files: Vec<std::path::PathBuf> = entries
1765 .filter_map(|e| e.ok())
1766 .map(|e| e.path())
1767 .filter(|p| {
1768 p.extension().is_some_and(|ext| ext == "md")
1769 && p.file_name().is_some_and(|n| n.to_string_lossy().contains("worker"))
1770 })
1771 .collect();
1772 if worker_files.len() > 50 {
1773 worker_files.sort_by_key(|p| {
1774 std::fs::metadata(p).and_then(|m| m.modified()).ok()
1775 });
1776 for old in worker_files.iter().take(10) {
1777 let _ = std::fs::remove_file(old);
1778 }
1779 }
1780 }
1781
1782 let ts = std::time::SystemTime::now()
1783 .duration_since(std::time::UNIX_EPOCH)
1784 .unwrap_or_default()
1785 .as_secs();
1786 for (i, result) in successful.iter().enumerate() {
1787 let body = if let Some(pos) = result.find('✅') {
1789 let after = &result[pos + '✅'.len_utf8()..];
1790 after.trim_start_matches('\n')
1791 } else {
1792 result.as_str()
1793 };
1794
1795 if body.len() <= 100 {
1797 continue;
1798 }
1799
1800 let hash_input = &body[..body.floor_char_boundary(200)];
1802 let hash: u64 = hash_input.bytes().map(|b| b as u64).sum::<u64>() % 100000;
1803 let hash_filename = format!("strat-hash-{hash}.md");
1804 if strategies_dir.join(&hash_filename).exists() {
1805 continue;
1806 }
1807
1808 let summary = &body[..body.floor_char_boundary(300)];
1809 let id = format!("strat-worker-{ts}-{i}");
1810 let content = format!(
1811 "---\nid: {id}\ntrigger: \"worker task result\"\nversion: 1\nstatus: active\n---\n# Worker Strategy {i}\n\n## Steps\n{summary}\n\n## Source\nPromoted from cluster worker result.\n"
1812 );
1813 let _ = std::fs::write(strategies_dir.join(&hash_filename), content);
1814 }
1815 }
1816 }
1817
1818 let header = dispatch_header(&specs);
1819 let footer = dispatch_footer(ok_count, specs.len());
1820 Ok(format!("{}\n\n{}\n\n{}", header, results.join("\n\n"), footer))
1821 }
1822}
1823
1824fn find_worker_binary() -> String {
1825 std::env::current_exe()
1829 .map(|p| p.display().to_string())
1830 .unwrap_or_else(|_| "aegis".to_string())
1831}
1832
1833async fn wait_for_tasks(task_ids: Vec<String>, timeout_secs: u64) -> Result<()> {
1838 let base_url = std::env::var("AEGIS_WORKER_URL")
1839 .unwrap_or_else(|_| "http://localhost:3001".to_string());
1840 let base_url = base_url.trim_end_matches('/').to_string();
1841
1842 let client = reqwest::Client::new();
1843 let deadline = tokio::time::Instant::now()
1844 + std::time::Duration::from_secs(timeout_secs);
1845
1846 let mut remaining: std::collections::HashSet<String> =
1847 task_ids.into_iter().collect();
1848
1849 while !remaining.is_empty() {
1850 if tokio::time::Instant::now() >= deadline {
1851 return Err(anyhow::anyhow!(
1852 "Timed out waiting for tasks: {:?}",
1853 remaining
1854 ));
1855 }
1856
1857 let mut completed = Vec::new();
1858 for task_id in remaining.iter() {
1859 let url = format!("{}/tasks/{}", base_url, task_id);
1860 match client.get(&url).send().await {
1861 Ok(resp) => {
1862 if let Ok(body) = resp.json::<serde_json::Value>().await {
1863 let status = body["status"].as_str().unwrap_or("");
1864 if status == "done" || status == "error" {
1865 completed.push(task_id.clone());
1866 }
1867 }
1868 }
1869 Err(_) => {
1870 }
1872 }
1873 }
1874 for id in completed {
1875 remaining.remove(&id);
1876 }
1877
1878 if !remaining.is_empty() {
1879 tokio::time::sleep(std::time::Duration::from_secs(2)).await;
1880 }
1881 }
1882 Ok(())
1883}
1884
1885fn create_worktree(repo: &std::path::Path, task_id: &str) -> Option<(std::path::PathBuf, String)> {
1889 let is_repo = std::process::Command::new("git")
1890 .arg("-C")
1891 .arg(repo)
1892 .args(["rev-parse", "--is-inside-work-tree"])
1893 .output()
1894 .ok()
1895 .map(|o| o.status.success())
1896 .unwrap_or(false);
1897 if !is_repo {
1898 return None;
1899 }
1900 let branch = format!("aegis/task-{task_id}");
1901 let wt = std::env::temp_dir().join(format!("aegis-wt-{task_id}"));
1902 let ok = std::process::Command::new("git")
1903 .arg("-C")
1904 .arg(repo)
1905 .args(["worktree", "add", "-b", &branch])
1906 .arg(&wt)
1907 .arg("HEAD")
1908 .output()
1909 .ok()
1910 .map(|o| o.status.success())
1911 .unwrap_or(false);
1912 if ok {
1913 Some((wt, branch))
1914 } else {
1915 None
1916 }
1917}
1918
1919async fn run_worker(bin: &str, prompt: &str, max_turns: u64, model: Option<String>, cwd: Option<std::path::PathBuf>) -> Result<String> {
1920 let mut params = serde_json::json!({ "prompt": prompt, "max_turns": max_turns });
1921 if let Some(m) = model.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
1924 params["model"] = serde_json::Value::String(m.to_string());
1925 }
1926 let request = serde_json::json!({
1927 "jsonrpc": "2.0", "id": 1, "method": "task/run",
1928 "params": params
1929 });
1930
1931 let mut child = tokio::process::Command::new(bin);
1932 child
1933 .arg("worker")
1934 .stdin(Stdio::piped())
1935 .stdout(Stdio::piped())
1936 .stderr(Stdio::null());
1937 if let Some(dir) = &cwd {
1938 child.current_dir(dir);
1939 }
1940 let mut child = child
1941 .spawn()
1942 .map_err(|e| anyhow::anyhow!("Failed to spawn worker: {e}. Could not run `{bin} worker`."))?;
1943
1944 if let Some(mut stdin) = child.stdin.take() {
1946 use tokio::io::AsyncWriteExt;
1947 stdin.write_all(format!("{}\n", request).as_bytes()).await?;
1948 drop(stdin);
1949 }
1950
1951 let output = tokio::time::timeout(
1953 std::time::Duration::from_secs(900), child.wait_with_output(),
1955 )
1956 .await
1957 .map_err(|_| anyhow::anyhow!("Worker timed out"))??;
1958
1959 let stdout = String::from_utf8_lossy(&output.stdout);
1960
1961 for line in stdout.lines().rev() {
1963 if let Ok(resp) = serde_json::from_str::<serde_json::Value>(line) {
1964 if let Some(result) = resp.get("result") {
1965 return Ok(result["result"]
1966 .as_str()
1967 .unwrap_or("(no result)")
1968 .to_string());
1969 }
1970 if let Some(error) = resp.get("error") {
1971 return Err(anyhow::anyhow!(
1972 "{}",
1973 error["message"].as_str().unwrap_or("unknown error")
1974 ));
1975 }
1976 }
1977 }
1978
1979 Ok(stdout.to_string())
1980}
1981
1982pub struct WebSearchTool;
1987
1988impl WebSearchTool {
1989 pub fn new() -> Self {
1991 WebSearchTool
1992 }
1993
1994 fn load_config_key(key: &str) -> Option<String> {
1995 {
1997 let config_path = aegis_types::paths::config_path();
1998 if let Ok(content) = std::fs::read_to_string(&config_path) {
1999 if let Ok(val) = content.parse::<toml::Value>() {
2000 if let Some(s) = val.get("tools").and_then(|t| t.get(key)).and_then(|v| v.as_str()) {
2001 if !s.is_empty() {
2002 return Some(s.to_string());
2003 }
2004 }
2005 }
2006 }
2007 }
2008 None
2009 }
2010}
2011
2012impl Default for WebSearchTool {
2013 fn default() -> Self {
2014 Self::new()
2015 }
2016}
2017
2018#[async_trait]
2019impl Tool for WebSearchTool {
2020 fn name(&self) -> &str {
2021 "web_search"
2022 }
2023
2024 fn description(&self) -> &str {
2025 "Search the web and return a list of results with title, URL, and snippet."
2026 }
2027
2028 fn parameters(&self) -> Value {
2029 json!({
2030 "type": "object",
2031 "properties": {
2032 "query": { "type": "string", "description": "Search query" },
2033 "num_results": { "type": "integer", "description": "Number of results (default 5)" }
2034 },
2035 "required": ["query"]
2036 })
2037 }
2038
2039 async fn execute(&self, args: Value, _ctx: &ToolContext<'_>) -> Result<String> {
2040 let query = args["query"].as_str().unwrap_or("").trim().to_string();
2041 if query.is_empty() {
2042 return Ok("Error: query is required".to_string());
2043 }
2044 let num_results = args["num_results"].as_u64().unwrap_or(5) as usize;
2045
2046 let exa_key = Self::load_config_key("exa_api_key")
2048 .or_else(|| std::env::var("EXA_API_KEY").ok());
2049 let tavily_key = Self::load_config_key("tavily_api_key")
2050 .or_else(|| std::env::var("TAVILY_API_KEY").ok());
2051
2052 if let Some(key) = exa_key {
2053 search_exa(&key, &query, num_results).await
2054 } else if let Some(key) = tavily_key {
2055 search_tavily(&key, &query, num_results).await
2056 } else {
2057 search_duckduckgo(&query, num_results).await
2058 }
2059 }
2060}
2061
2062async fn search_exa(api_key: &str, query: &str, num_results: usize) -> Result<String> {
2063 use aegis_security::is_safe_url;
2064 let url = "https://api.exa.ai/search";
2065 is_safe_url(url).map_err(|e| anyhow::anyhow!("SSRF check failed: {e}"))?;
2066
2067 let client = reqwest::Client::new();
2068 let resp: Value = client
2069 .post(url)
2070 .header("x-api-key", api_key)
2071 .header("Content-Type", "application/json")
2072 .json(&json!({ "query": query, "numResults": num_results }))
2073 .send()
2074 .await?
2075 .json()
2076 .await?;
2077
2078 let mut out = String::new();
2079 if let Some(results) = resp["results"].as_array() {
2080 for (i, r) in results.iter().enumerate() {
2081 let title = r["title"].as_str().unwrap_or("(no title)");
2082 let url = r["url"].as_str().unwrap_or("");
2083 let snippet = r["text"].as_str().or_else(|| r["snippet"].as_str()).unwrap_or("");
2084 let snippet = if snippet.len() > 300 { &snippet[..snippet.floor_char_boundary(300)] } else { snippet };
2085 out.push_str(&format!("[{}] {}\n{}\n{}\n\n", i + 1, title, url, snippet));
2086 }
2087 }
2088 if out.is_empty() {
2089 out = "No results found.".to_string();
2090 }
2091 Ok(out.trim_end().to_string())
2092}
2093
2094async fn search_tavily(api_key: &str, query: &str, num_results: usize) -> Result<String> {
2095 use aegis_security::is_safe_url;
2096 let url = "https://api.tavily.com/search";
2097 is_safe_url(url).map_err(|e| anyhow::anyhow!("SSRF check failed: {e}"))?;
2098
2099 let client = reqwest::Client::new();
2100 let resp: Value = client
2101 .post(url)
2102 .json(&json!({
2103 "api_key": api_key,
2104 "query": query,
2105 "max_results": num_results
2106 }))
2107 .send()
2108 .await?
2109 .json()
2110 .await?;
2111
2112 let mut out = String::new();
2113 if let Some(results) = resp["results"].as_array() {
2114 for (i, r) in results.iter().enumerate() {
2115 let title = r["title"].as_str().unwrap_or("(no title)");
2116 let url = r["url"].as_str().unwrap_or("");
2117 let snippet = r["content"].as_str().unwrap_or("");
2118 let snippet = if snippet.len() > 300 { &snippet[..snippet.floor_char_boundary(300)] } else { snippet };
2119 out.push_str(&format!("[{}] {}\n{}\n{}\n\n", i + 1, title, url, snippet));
2120 }
2121 }
2122 if out.is_empty() {
2123 out = "No results found.".to_string();
2124 }
2125 Ok(out.trim_end().to_string())
2126}
2127
2128async fn search_duckduckgo(query: &str, num_results: usize) -> Result<String> {
2129 use aegis_security::is_safe_url;
2130 let encoded = urlencoding_encode(query);
2131 let url = format!("https://html.duckduckgo.com/html/?q={}", encoded);
2132 is_safe_url(&url).map_err(|e| anyhow::anyhow!("SSRF check failed: {e}"))?;
2133
2134 let client = reqwest::Client::builder()
2135 .user_agent("Mozilla/5.0 (compatible; aegis-agent/0.1)")
2136 .build()?;
2137 let html = client.get(&url).send().await?.text().await?;
2138
2139 let document = scraper::Html::parse_document(&html);
2140 let result_sel = scraper::Selector::parse(".result").map_err(|e| anyhow::anyhow!("Invalid selector: {e}"))?;
2141 let title_sel = scraper::Selector::parse(".result__title a, .result__a").map_err(|e| anyhow::anyhow!("Invalid selector: {e}"))?;
2142 let snippet_sel = scraper::Selector::parse(".result__snippet").map_err(|e| anyhow::anyhow!("Invalid selector: {e}"))?;
2143 let url_sel = scraper::Selector::parse(".result__url").map_err(|e| anyhow::anyhow!("Invalid selector: {e}"))?;
2144
2145 let mut out = String::new();
2146 let mut count = 0;
2147
2148 for result in document.select(&result_sel) {
2149 if count >= num_results {
2150 break;
2151 }
2152 let title = result.select(&title_sel).next()
2153 .map(|e| e.text().collect::<Vec<_>>().join(""))
2154 .unwrap_or_default();
2155 let title = title.trim().to_string();
2156 if title.is_empty() {
2157 continue;
2158 }
2159 let url = result.select(&url_sel).next()
2160 .map(|e| e.text().collect::<Vec<_>>().join("").trim().to_string())
2161 .unwrap_or_default();
2162 let snippet = result.select(&snippet_sel).next()
2163 .map(|e| e.text().collect::<Vec<_>>().join("").trim().to_string())
2164 .unwrap_or_default();
2165 let snippet = if snippet.len() > 300 { snippet[..snippet.floor_char_boundary(300)].to_string() } else { snippet };
2166
2167 count += 1;
2168 out.push_str(&format!("[{count}] {title}\n{url}\n{snippet}\n\n"));
2169 }
2170
2171 if out.is_empty() {
2172 out = "No results found (DuckDuckGo fallback).".to_string();
2173 }
2174 Ok(out.trim_end().to_string())
2175}
2176
2177fn urlencoding_encode(s: &str) -> String {
2178 let mut out = String::new();
2179 for b in s.bytes() {
2180 match b {
2181 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
2182 out.push(b as char);
2183 }
2184 b' ' => out.push('+'),
2185 _ => out.push_str(&format!("%{:02X}", b)),
2186 }
2187 }
2188 out
2189}
2190
2191pub struct WebExtractTool;
2196
2197impl WebExtractTool {
2198 pub fn new() -> Self {
2200 WebExtractTool
2201 }
2202}
2203
2204impl Default for WebExtractTool {
2205 fn default() -> Self {
2206 Self::new()
2207 }
2208}
2209
2210#[async_trait]
2211impl Tool for WebExtractTool {
2212 fn name(&self) -> &str {
2213 "web_extract"
2214 }
2215
2216 fn description(&self) -> &str {
2217 "Fetch a URL and convert its main content to clean Markdown (readability extraction + HTML→Markdown). Set format='text' for plain text."
2218 }
2219
2220 fn parameters(&self) -> Value {
2221 json!({
2222 "type": "object",
2223 "properties": {
2224 "url": { "type": "string", "description": "URL to fetch and extract" },
2225 "format": { "type": "string", "enum": ["markdown", "text"], "description": "Output format (default: markdown)" },
2226 "max_chars": { "type": "integer", "description": "Truncate output to this many characters (default 16000; 0 = no limit)" }
2227 },
2228 "required": ["url"]
2229 })
2230 }
2231
2232 async fn execute(&self, args: Value, ctx: &ToolContext<'_>) -> Result<String> {
2233 use aegis_security::is_safe_url;
2234
2235 let url = args["url"].as_str().unwrap_or("").trim();
2236 if url.is_empty() {
2237 return Ok("Error: url is required".to_string());
2238 }
2239
2240 if ctx.sandbox_enabled {
2245 let identity = ctx.effective_identity();
2246 let policy =
2247 aegis_security::derive_sandbox_policy(&identity, "web_extract", &ctx.cwd);
2248 if policy.deny_all {
2249 return Ok(format!(
2250 "web_extract denied by sandbox policy: identity '{}' with trust level '{}' is not authorized to fetch external URLs.",
2251 identity.display(),
2252 identity.trust_level(),
2253 ));
2254 }
2255 tracing::debug!(
2256 identity = %identity.display(),
2257 trust = %identity.trust_level(),
2258 url = %url,
2259 "web_extract: identity check passed"
2260 );
2261 }
2262
2263 is_safe_url(url).map_err(|e| anyhow::anyhow!("SSRF check failed: {e}"))?;
2264
2265 let format = args["format"].as_str().unwrap_or("markdown");
2266 let max_chars = args["max_chars"].as_u64().map(|v| v as usize).unwrap_or(16_000);
2268
2269 let client = reqwest::Client::builder()
2270 .user_agent("Mozilla/5.0 (compatible; aegis-agent/0.1)")
2271 .build()?;
2272
2273 let mut resp = client.get(url).send().await?;
2274
2275 const MAX_BYTES: usize = 10 * 1024 * 1024; let content_type = resp
2279 .headers()
2280 .get(reqwest::header::CONTENT_TYPE)
2281 .and_then(|v| v.to_str().ok())
2282 .unwrap_or("")
2283 .to_lowercase();
2284 let mut body: Vec<u8> = Vec::new();
2285 while let Some(chunk) = resp.chunk().await? {
2286 body.extend_from_slice(&chunk);
2287 if body.len() >= MAX_BYTES {
2288 body.truncate(MAX_BYTES);
2289 break;
2290 }
2291 }
2292
2293 let is_html = content_type.contains("html") || content_type.is_empty();
2295 let content = if !is_html && content_type.starts_with("text/") {
2296 String::from_utf8_lossy(&body).into_owned()
2297 } else {
2298 let html = String::from_utf8_lossy(&body).into_owned();
2299 if format == "text" {
2302 let document = scraper::Html::parse_document(&html);
2303 compress_whitespace(&extract_text_content(&document))
2304 } else {
2305 html_to_markdown(&html, url)
2306 }
2307 };
2308
2309 if max_chars > 0 && content.chars().count() > max_chars {
2310 let truncated: String = content.chars().take(max_chars).collect();
2311 Ok(format!(
2312 "{truncated}\n\n... [content truncated at {max_chars} characters. Use a more specific URL, or pass a larger max_chars.]"
2313 ))
2314 } else {
2315 Ok(content)
2316 }
2317 }
2318}
2319
2320fn html_to_markdown(html: &str, url: &str) -> String {
2328 let (title, body_html) = match extract_readable(html, url) {
2329 Some((t, c)) if !c.trim().is_empty() => (Some(t), c),
2330 _ => (None, html.to_string()),
2331 };
2332
2333 let body_md = match htmd::convert(&body_html) {
2334 Ok(md) => md,
2335 Err(_) => {
2336 let document = scraper::Html::parse_document(html);
2338 compress_whitespace(&extract_text_content(&document))
2339 }
2340 };
2341
2342 let body_md = body_md.trim();
2343 match title {
2344 Some(t) if !t.trim().is_empty() && !body_md.starts_with("# ") => {
2345 format!("# {}\n\n{}", t.trim(), body_md)
2346 }
2347 _ => body_md.to_string(),
2348 }
2349}
2350
2351fn extract_readable(html: &str, url: &str) -> Option<(String, String)> {
2354 let url_opt = if url.starts_with("http://") || url.starts_with("https://") {
2355 Some(url)
2356 } else {
2357 None
2358 };
2359 let mut readability = dom_smoothie::Readability::new(html, url_opt, None).ok()?;
2360 let article = readability.parse().ok()?;
2361 Some((article.title.clone(), article.content.to_string()))
2362}
2363
2364fn extract_text_content(document: &scraper::Html) -> String {
2365 for selector_str in &["article", "main", "body"] {
2367 if let Ok(sel) = scraper::Selector::parse(selector_str) {
2368 if let Some(elem) = document.select(&sel).next() {
2369 return collect_text_skip_scripts(&elem);
2370 }
2371 }
2372 }
2373 document.root_element().text().collect::<Vec<_>>().join(" ")
2375}
2376
2377fn collect_text_skip_scripts(elem: &scraper::ElementRef) -> String {
2378 let mut text = String::new();
2379 for node in elem.descendants() {
2380 if let Some(el) = scraper::ElementRef::wrap(node) {
2381 let tag = el.value().name();
2382 if tag == "script" || tag == "style" || tag == "noscript" {
2383 continue;
2384 }
2385 } else if let Some(t) = node.value().as_text() {
2386 text.push_str(t);
2387 }
2388 }
2389 text
2390}
2391
2392fn compress_whitespace(s: &str) -> String {
2393 let mut result = String::with_capacity(s.len());
2394 let mut last_was_space = true;
2395 for c in s.chars() {
2396 if c.is_whitespace() {
2397 if !last_was_space {
2398 result.push(' ');
2399 last_was_space = true;
2400 }
2401 } else {
2402 result.push(c);
2403 last_was_space = false;
2404 }
2405 }
2406 result.trim().to_string()
2407}
2408
2409pub struct BrowserTool {
2414 pub binary: String,
2415 pub timeout_secs: u64,
2416}
2417
2418#[async_trait]
2419impl Tool for BrowserTool {
2420 fn name(&self) -> &str {
2421 "browser"
2422 }
2423 fn description(&self) -> &str {
2424 "Control a real browser via browser-harness. Actions: navigate, click, type, screenshot, extract."
2425 }
2426 fn parameters(&self) -> Value {
2427 json!({
2428 "type": "object",
2429 "properties": {
2430 "action": {
2431 "type": "string",
2432 "enum": ["navigate", "click", "type", "screenshot", "extract"],
2433 "description": "Browser action to perform"
2434 },
2435 "url": { "type": "string", "description": "URL to navigate to (navigate)" },
2436 "x": { "type": "number", "description": "X coordinate (click, type)" },
2437 "y": { "type": "number", "description": "Y coordinate (click, type)" },
2438 "text": { "type": "string", "description": "Text to type (type)" },
2439 "selector":{ "type": "string", "description": "CSS selector to click or extract (optional)" }
2440 },
2441 "required": ["action"]
2442 })
2443 }
2444 async fn execute(&self, args: Value, _ctx: &ToolContext<'_>) -> Result<String> {
2445 let action = args["action"].as_str().unwrap_or("");
2446
2447 let code = match action {
2448 "navigate" => {
2449 let url = args["url"].as_str().unwrap_or("");
2450 if url.is_empty() {
2451 return Ok("Error: url is required for navigate".to_string());
2452 }
2453 format!(
2454 "from browser_harness import Browser\nb = Browser()\nb.go('{url}')\nprint(b.title())\n"
2455 )
2456 }
2457 "click" => {
2458 if let Some(sel) = args["selector"].as_str() {
2459 format!(
2460 "from browser_harness import Browser\nb = Browser()\nb.click('{sel}')\nprint('clicked')\n"
2461 )
2462 } else {
2463 let x = args["x"].as_f64().unwrap_or(0.0);
2464 let y = args["y"].as_f64().unwrap_or(0.0);
2465 format!(
2466 "from browser_harness import Browser\nb = Browser()\nb.click_at({x}, {y})\nprint('clicked')\n"
2467 )
2468 }
2469 }
2470 "type" => {
2471 let text = args["text"].as_str().unwrap_or("");
2472 if let Some(sel) = args["selector"].as_str() {
2473 format!(
2474 "from browser_harness import Browser\nb = Browser()\nb.type('{sel}', '{text}')\nprint('typed')\n"
2475 )
2476 } else {
2477 let x = args["x"].as_f64().unwrap_or(0.0);
2478 let y = args["y"].as_f64().unwrap_or(0.0);
2479 format!(
2480 "from browser_harness import Browser\nb = Browser()\nb.click_at({x}, {y})\nb.keyboard_type('{text}')\nprint('typed')\n"
2481 )
2482 }
2483 }
2484 "screenshot" => {
2485 "from browser_harness import Browser\nb = Browser()\npath = b.screenshot()\nprint(path)\n".to_string()
2486 }
2487 "extract" => {
2488 if let Some(sel) = args["selector"].as_str() {
2489 format!(
2490 "from browser_harness import Browser\nb = Browser()\nprint(b.text('{sel}'))\n"
2491 )
2492 } else {
2493 "from browser_harness import Browser\nb = Browser()\nprint(b.text('body'))\n".to_string()
2494 }
2495 }
2496 _ => return Ok(format!("Unknown action: {action}. Use: navigate, click, type, screenshot, extract")),
2497 };
2498
2499 let output = tokio::time::timeout(
2500 std::time::Duration::from_secs(self.timeout_secs),
2501 tokio::process::Command::new(&self.binary)
2502 .arg("-c")
2503 .arg(&code)
2504 .stdout(Stdio::piped())
2505 .stderr(Stdio::piped())
2506 .output(),
2507 )
2508 .await
2509 .map_err(|_| anyhow::anyhow!("browser-harness timed out after {}s", self.timeout_secs))??;
2510
2511 let stdout = String::from_utf8_lossy(&output.stdout);
2512 let stderr = String::from_utf8_lossy(&output.stderr);
2513 let code_exit = output.status.code().unwrap_or(-1);
2514
2515 if code_exit != 0 && stdout.is_empty() {
2516 return Ok(format!("[browser error]\n{stderr}"));
2517 }
2518 let mut result = stdout.to_string();
2519 if !stderr.is_empty() {
2520 result.push_str(&format!("\n[stderr] {stderr}"));
2521 }
2522 Ok(result.trim_end().to_string())
2523 }
2524}
2525
2526pub struct MaigretTool {
2531 pub maigret_path: String,
2532 pub timeout_secs: u64,
2533 pub top_sites: u64,
2534}
2535
2536#[async_trait]
2537impl Tool for MaigretTool {
2538 fn name(&self) -> &str {
2539 "maigret"
2540 }
2541 fn description(&self) -> &str {
2542 "Search for social media accounts and public profiles by username using Maigret OSINT tool. Returns found accounts across hundreds of sites."
2543 }
2544 fn parameters(&self) -> Value {
2545 json!({
2546 "type": "object",
2547 "properties": {
2548 "username": {
2549 "type": "string",
2550 "description": "Username to search for"
2551 },
2552 "top_sites": {
2553 "type": "integer",
2554 "description": "Limit to top N sites (default: configured value, 0 = all)"
2555 }
2556 },
2557 "required": ["username"]
2558 })
2559 }
2560 async fn execute(&self, args: Value, _ctx: &ToolContext<'_>) -> Result<String> {
2561 let username = args["username"].as_str().unwrap_or("").trim().to_string();
2562 if username.is_empty() {
2563 return Ok("Error: username is required.".to_string());
2564 }
2565 if username.contains([';', '&', '|', '$', '`', '\'', '"', '\n']) {
2567 return Ok("Error: invalid characters in username.".to_string());
2568 }
2569
2570 let top = args["top_sites"].as_u64().unwrap_or(self.top_sites);
2571
2572 let mut cmd = tokio::process::Command::new("python");
2573 cmd.arg("-m")
2574 .arg("maigret")
2575 .arg(&username)
2576 .arg("--no-color")
2577 .arg("--no-progressbar")
2578 .arg("-J")
2579 .arg("ndjson");
2580 if top > 0 {
2581 cmd.arg("--top-sites").arg(top.to_string());
2582 }
2583 cmd.current_dir(&self.maigret_path)
2584 .stdout(Stdio::piped())
2585 .stderr(Stdio::piped());
2586
2587 let output = tokio::time::timeout(
2588 std::time::Duration::from_secs(self.timeout_secs),
2589 cmd.output(),
2590 )
2591 .await
2592 .map_err(|_| anyhow::anyhow!("maigret timed out after {}s", self.timeout_secs))??;
2593
2594 let stdout = String::from_utf8_lossy(&output.stdout);
2595 let stderr = String::from_utf8_lossy(&output.stderr);
2596
2597 let report_path = std::path::Path::new(&self.maigret_path)
2599 .join("reports")
2600 .join(format!("report_{username}_ndjson.json"));
2601
2602 if report_path.exists() {
2603 let json_raw = tokio::fs::read_to_string(&report_path).await?;
2604 if let Ok(data) = serde_json::from_str::<Value>(&json_raw) {
2606 return Ok(format_maigret_report(&username, &data));
2607 }
2608 }
2609
2610 let mut result = stdout.to_string();
2612 if result.is_empty() && !stderr.is_empty() {
2613 result = stderr.to_string();
2614 }
2615 Ok(truncate_output(&result, 20_000))
2616 }
2617}
2618
2619fn format_maigret_report(username: &str, data: &Value) -> String {
2620 let mut found = Vec::new();
2621 let mut not_found = 0usize;
2622
2623 if let Some(obj) = data.as_object() {
2624 for (site, info) in obj {
2625 let status = info["status"]["status"].as_str().unwrap_or("");
2626 if status == "Claimed" {
2627 let url = info["url_user"].as_str()
2628 .or_else(|| info["url"].as_str())
2629 .unwrap_or("");
2630 let mut extra = Vec::new();
2631 for key in &["name", "bio", "location", "followers", "following", "posts"] {
2633 if let Some(v) = info["ids"].get(key).or_else(|| info.get(key)) {
2634 if !v.is_null() {
2635 extra.push(format!("{key}={v}"));
2636 }
2637 }
2638 }
2639 let extra_str = if extra.is_empty() {
2640 String::new()
2641 } else {
2642 format!(" [{}]", extra.join(", "))
2643 };
2644 found.push(format!(" + {site}: {url}{extra_str}"));
2645 } else {
2646 not_found += 1;
2647 }
2648 }
2649 }
2650
2651 found.sort();
2652 let mut out = format!(
2653 "Maigret results for '{}': {} accounts found, {} not found\n\n",
2654 username,
2655 found.len(),
2656 not_found
2657 );
2658 if found.is_empty() {
2659 out.push_str("No accounts found.\n");
2660 } else {
2661 out.push_str(&found.join("\n"));
2662 }
2663 out
2664}
2665
2666#[cfg(test)]
2667mod tests {
2668 use super::*;
2669 use serde_json::json;
2670
2671 #[test]
2672 fn bg_backend_from_config_parses() {
2673 assert_eq!(BgBackend::from_config("tmux"), BgBackend::Tmux);
2674 assert_eq!(BgBackend::from_config(" TMUX "), BgBackend::Tmux);
2675 assert_eq!(BgBackend::from_config("child"), BgBackend::Child);
2676 assert_eq!(BgBackend::from_config("auto"), BgBackend::Auto);
2677 assert_eq!(BgBackend::from_config("nonsense"), BgBackend::Auto);
2678 }
2679
2680 #[test]
2681 fn bg_backend_forced_passthrough() {
2682 assert_eq!(BgBackend::Child.effective(), BgBackend::Child);
2684 assert_eq!(BgBackend::Tmux.effective(), BgBackend::Tmux);
2685 }
2686
2687 #[test]
2688 fn tmux_session_name_sanitizes_and_prefixes() {
2689 assert_eq!(tmux_session_name("job1"), "aegis-job1");
2690 assert_eq!(tmux_session_name("a b;c$(x)"), "aegis-a_b_c__x_");
2691 assert_eq!(tmux_session_name("ok-_1"), "aegis-ok-_1");
2692 }
2693
2694 #[test]
2695 fn posix_squote_escapes_single_quotes() {
2696 assert_eq!(posix_squote("abc"), "'abc'");
2697 assert_eq!(posix_squote("a'b"), "'a'\\''b'");
2698 }
2699
2700 #[test]
2701 fn html_to_markdown_preserves_structure() {
2702 let html = r#"<html><head><title>Doc Title</title></head><body>
2703 <article>
2704 <h1>Heading One</h1>
2705 <p>A paragraph with <a href="https://example.com">a link</a>.</p>
2706 <ul><li>first</li><li>second</li></ul>
2707 <pre><code>let x = 1;</code></pre>
2708 </article></body></html>"#;
2709 let md = html_to_markdown(html, "https://example.com/page");
2710 assert!(md.contains("Heading One"), "missing heading: {md}");
2712 assert!(md.contains("first") && md.contains("second"), "missing list: {md}");
2713 assert!(md.contains("- ") || md.contains("* "), "list not markdown: {md}");
2714 assert!(md.contains("example.com"), "missing link: {md}");
2715 }
2716
2717 #[test]
2718 fn html_to_markdown_falls_back_on_garbage() {
2719 let md = html_to_markdown("<p>just text</p>", "not-a-url");
2721 assert!(md.contains("just text"), "got: {md}");
2722 }
2723
2724 fn make_ctx(cwd: std::path::PathBuf) -> ToolContext<'static> {
2725 ToolContext {
2726 cwd,
2727 session_id: "test".to_string(),
2728 approve_fn: &|_| true,
2729 yolo: true,
2730 identity: None,
2731 sandbox_enabled: false,
2732 }
2733 }
2734
2735 #[tokio::test]
2736 async fn test_spawn_without_depends() {
2737 let tool = SpawnTaskTool::new(1);
2738 let ctx = make_ctx(std::env::temp_dir());
2739 let result = tool.execute(json!({}), &ctx).await;
2741 assert!(result.is_ok());
2742 assert_eq!(result.unwrap(), "No prompt provided.");
2743 }
2744
2745 #[tokio::test]
2746 async fn test_spawn_with_empty_depends() {
2747 let tool = SpawnTaskTool::new(1);
2749 let ctx = make_ctx(std::env::temp_dir());
2750 let result = tool.execute(json!({"depends_on": []}), &ctx).await;
2751 assert!(result.is_ok());
2752 assert_eq!(result.unwrap(), "No prompt provided.");
2753 }
2754
2755 #[test]
2756 fn parse_specs_single_prompt_defaults() {
2757 let specs = parse_task_specs(&json!({ "prompt": "look at logs" }));
2758 assert_eq!(specs.len(), 1);
2759 assert_eq!(specs[0].prompt, "look at logs");
2760 assert_eq!(specs[0].model, None);
2761 assert_eq!(specs[0].max_turns, 20);
2762 }
2763
2764 #[test]
2765 fn parse_specs_single_with_top_level_model_and_turns() {
2766 let specs = parse_task_specs(&json!({
2767 "prompt": "deep dive", "model": "o1", "max_turns": 40
2768 }));
2769 assert_eq!(specs.len(), 1);
2770 assert_eq!(specs[0].model.as_deref(), Some("o1"));
2771 assert_eq!(specs[0].max_turns, 40);
2772 }
2773
2774 #[test]
2775 fn parse_specs_batch_heterogeneous_models() {
2776 let specs = parse_task_specs(&json!({
2777 "model": "gpt-4o-mini",
2778 "max_turns": 15,
2779 "tasks": [
2780 { "prompt": "suspicious lead", "model": "gpt-4o", "max_turns": 30 },
2781 { "prompt": "bulk check A" },
2782 { "prompt": "bulk check B", "model": " o1 " }
2783 ]
2784 }));
2785 assert_eq!(specs.len(), 3);
2786 assert_eq!(specs[0].model.as_deref(), Some("gpt-4o"));
2787 assert_eq!(specs[0].max_turns, 30);
2788 assert_eq!(specs[1].model.as_deref(), Some("gpt-4o-mini"));
2789 assert_eq!(specs[1].max_turns, 15);
2790 assert_eq!(specs[2].model.as_deref(), Some("o1"));
2791 assert_eq!(specs[2].max_turns, 15);
2792 }
2793
2794 #[test]
2795 fn parse_specs_batch_skips_items_without_prompt() {
2796 let specs = parse_task_specs(&json!({
2797 "tasks": [ { "model": "gpt-4o" }, { "prompt": "real one" } ]
2798 }));
2799 assert_eq!(specs.len(), 1);
2800 assert_eq!(specs[0].prompt, "real one");
2801 }
2802
2803 #[test]
2804 fn parse_specs_empty_when_nothing_provided() {
2805 assert!(parse_task_specs(&json!({})).is_empty());
2806 }
2807
2808 #[test]
2809 fn model_breakdown_groups_and_counts() {
2810 let specs = parse_task_specs(&json!({
2811 "model": "gpt-4o-mini",
2812 "tasks": [
2813 { "prompt": "a", "model": "gpt-4o" },
2814 { "prompt": "b" },
2815 { "prompt": "c" }
2816 ]
2817 }));
2818 let bd = model_breakdown(&specs);
2820 assert!(bd.contains("1×gpt-4o"), "got: {bd}");
2821 assert!(bd.contains("2×gpt-4o-mini"), "got: {bd}");
2822 }
2823
2824 #[test]
2825 fn model_breakdown_default_when_no_override() {
2826 let specs = parse_task_specs(&json!({ "tasks": [{ "prompt": "a" }, { "prompt": "b" }] }));
2827 assert_eq!(model_breakdown(&specs), "2×default model");
2828 }
2829
2830 #[test]
2831 fn dispatch_header_singular_vs_plural() {
2832 let one = parse_task_specs(&json!({ "prompt": "x" }));
2833 assert!(dispatch_header(&one).contains("1 sub-agent "));
2834 let many = parse_task_specs(&json!({ "tasks": [{ "prompt": "a" }, { "prompt": "b" }] }));
2835 assert!(dispatch_header(&many).contains("2 sub-agents"));
2836 }
2837
2838 #[test]
2839 fn dispatch_footer_counts_failures() {
2840 assert_eq!(dispatch_footer(11, 12), "── 12 sub-agents · 11 ✅ / 1 ❌ ──");
2841 assert_eq!(dispatch_footer(3, 3), "── 3 sub-agents · 3 ✅ / 0 ❌ ──");
2842 assert_eq!(dispatch_footer(5, 3), "── 3 sub-agents · 5 ✅ / 0 ❌ ──");
2844 }
2845
2846 #[test]
2847 fn cap_summary_passes_short_text_unchanged() {
2848 assert_eq!(cap_summary("short result", 2000), "short result");
2849 let exactly = "x".repeat(50);
2850 assert_eq!(cap_summary(&exactly, 50), exactly);
2851 }
2852
2853 #[test]
2854 fn cap_summary_truncates_long_text_with_note() {
2855 let long = "y".repeat(5000);
2856 let out = cap_summary(&long, 100);
2857 assert!(out.starts_with(&"y".repeat(100)));
2858 assert!(out.contains("truncated to 100 chars"));
2859 assert!(out.contains("sub-agent's own session"));
2860 assert_eq!(out.chars().take_while(|c| *c == 'y').count(), 100);
2862 }
2863
2864 #[test]
2865 fn cap_summary_multibyte_does_not_panic() {
2866 let s = "配置项排查结论一二三四"; let out = cap_summary(s, 4);
2869 assert!(out.starts_with("配置项排"));
2870 assert!(out.contains("truncated to 4 chars"));
2871 }
2872
2873 #[tokio::test]
2874 async fn test_wait_for_tasks_empty() {
2875 let result = wait_for_tasks(vec![], 5).await;
2877 assert!(result.is_ok());
2878 }
2879
2880 #[tokio::test]
2881 async fn test_wait_for_tasks_timeout() {
2882 let result = wait_for_tasks(vec!["nonexistent-task-id".to_string()], 1).await;
2885 assert!(result.is_err());
2886 let err = result.unwrap_err().to_string();
2887 assert!(err.contains("Timed out") || err.contains("timed out") || err.contains("nonexistent"));
2888 }
2889}
2890