From e471f9bc5434baffaa4acaa3b1e8093516d7bdcd Mon Sep 17 00:00:00 2001 From: AuroraCrimsonRose Date: Wed, 3 Jun 2026 06:01:06 -0500 Subject: [PATCH] Added many tools --- core/config.py | 6 + main.py | 2 +- memory_store.json | 1 + tools/__init__.py | 42 ++++-- tools/agent_loop.py | 178 +++++++++++++++++++++++++ tools/bochs.py | 141 ++++++++++++++++++++ tools/cmake.py | 188 ++++++++++++++++++++++++++ tools/crawler.py | 194 +++++++++++++++++++++++++++ tools/discovery.py | 40 +++++- tools/docker.py | 225 ++++++++++++++++++++++++++++++++ tools/ffmpeg.py | 254 ++++++++++++++++++++++++++++++++++++ tools/filesystem.py | 197 +++++++++------------------- tools/gitea.py | 10 +- tools/gpu.py | 175 +++++++++++++++++++++++++ tools/info.py | 190 +++++++++++++++++++++++++++ tools/intelligent_search.py | 183 ++++++++++++++++++++++++++ tools/memory.py | 159 ++++++++++++++++++++++ tools/net.py | 167 ++++++++++++++++++++++++ tools/ollama.py | 158 ++++++++++++++++++++++ tools/pid.py | 166 +++++++++++++++++++++++ tools/ping.py | 22 ++-- tools/pwsh.py | 136 +++++++++++++++++++ tools/qemu.py | 155 ++++++++++++++++++++++ tools/reflection.py | 177 +++++++++++++++++++++++++ tools/research.py | 149 +++++++++++++++++++++ tools/search.py | 117 +++++++++++++++++ tools/subprocess.py | 73 ++++++----- tools/venv.py | 188 ++++++++++++++++++++++++++ 28 files changed, 3488 insertions(+), 205 deletions(-) create mode 100644 memory_store.json create mode 100644 tools/agent_loop.py create mode 100644 tools/crawler.py create mode 100644 tools/gpu.py create mode 100644 tools/info.py create mode 100644 tools/intelligent_search.py create mode 100644 tools/memory.py create mode 100644 tools/net.py create mode 100644 tools/ollama.py create mode 100644 tools/pid.py create mode 100644 tools/pwsh.py create mode 100644 tools/reflection.py create mode 100644 tools/research.py create mode 100644 tools/search.py diff --git a/core/config.py b/core/config.py index db85d4f..33560f7 100644 --- a/core/config.py +++ b/core/config.py @@ -13,12 +13,18 @@ WORKSPACE_ROOT = Path(os.getenv("WORKSPACE_ROOT", Path.cwd())).resolve() LOG_DIR = WORKSPACE_ROOT / "logs" TMP_DIR = WORKSPACE_ROOT / "tmp" + CONFIG_DIR = WORKSPACE_ROOT / "config" LOG_DIR.mkdir(parents=True, exist_ok=True) TMP_DIR.mkdir(parents=True, exist_ok=True) CONFIG_DIR.mkdir(parents=True, exist_ok=True) +# ========================= +# OLLAMA CONFIG +# ========================= + +OLLAMA_URL = "http://localhost:11434" # ========================= # SYSTEM LIMITS diff --git a/main.py b/main.py index e3ba801..8ee1fee 100644 --- a/main.py +++ b/main.py @@ -167,7 +167,7 @@ def shutdown_gracefully() -> None: """Clean shutdown of executor and other resources.""" logger.info("Initiating graceful shutdown...") try: - executor.shutdown() + executor.shutdown() # type: ignore[attr-defined] logger.info("Executor shut down successfully.") except Exception as e: logger.error(f"Error during executor shutdown: {e}") diff --git a/memory_store.json b/memory_store.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/memory_store.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/tools/__init__.py b/tools/__init__.py index a0bf8c4..44d909a 100644 --- a/tools/__init__.py +++ b/tools/__init__.py @@ -1,16 +1,36 @@ """ -Tool bootstrap. +Tool bootstrap module. -Importing this module forces -tool registration into registry. +This package previously used explicit imports to force tool registration, +but has since moved to a dynamic discovery system. + +Current system behavior: +- tools are loaded via tools.discovery.load_all_tools() +- each tool self-registers into the global registry +- MCP bindings happen after registry population """ -from tools.ping import PingTool -from tools.filesystem import FilesystemTool -from tools.subprocess import SubprocessTool +# ------------------------------------------------------------ +# LEGACY APPROACH (STATIC IMPORT REGISTRATION) +# ------------------------------------------------------------ +# These imports were previously used to force tool registration +# at import-time. This approach was replaced to support scalable +# plugin discovery via pkgutil-based loading. +# +# from tools.ping import PingTool +# from tools.filesystem import FilesystemTool +# from tools.subprocess import SubprocessTool +# +# __all__ = [ +# "PingTool", +# "FilesystemTool", +# "SubprocessTool", +# ] -__all__ = [ - "PingTool", - "FilesystemTool", - "SubprocessTool", -] \ No newline at end of file +# ------------------------------------------------------------ +# CURRENT DESIGN +# ------------------------------------------------------------ +# Tool registration is now fully dynamic. +# See: tools.discovery.load_all_tools() +# +# No explicit imports are required here. \ No newline at end of file diff --git a/tools/agent_loop.py b/tools/agent_loop.py new file mode 100644 index 0000000..e5c4e67 --- /dev/null +++ b/tools/agent_loop.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +from typing import Any + +from core.tools.registry import registry +from core.events import bus + + +class AgentLoop: + """ + Minimal deterministic agent loop. + + Orchestrates tools like: + - search + - intelligent_search + - crawler + - research + + Can later be upgraded with LLM-based planning. + """ + + def __init__(self, ctx): + self.ctx = ctx + self.max_steps = 5 + + # ========================= + # ENTRY POINT + # ========================= + + def run(self, goal: str) -> dict[str, Any]: + state = { + "goal": goal, + "steps": [], + "memory": [], + "final": None + } + + bus.log( + "AGENT", + "agent_loop_start", + "INFO", + {"goal": goal} + ) + + for step in range(self.max_steps): + action = self._decide(state) + + if action["type"] == "stop": + state["final"] = action.get("result") + break + + result = self._execute(action, state) + state["steps"].append(action) + state["memory"].append(result) + + # simple convergence heuristic + if self._is_satisfied(state): + state["final"] = result + break + + if not state["final"]: + state["final"] = state["memory"][-1] if state["memory"] else {} + + return state + + # ========================= + # DECISION LOGIC (RULE-BASED FOR NOW) + # ========================= + + def _decide(self, state: dict[str, Any]) -> dict[str, Any]: + """ + Very simple heuristic planner. + + Later upgrade point: replace with LLM planner. + """ + + goal = state["goal"] + memory = state["memory"] + + if not memory: + return { + "type": "tool", + "tool": "research", + "input": { + "query": goal, + "depth": 1 + } + } + + last = memory[-1] + + # if research returned sources, refine or stop + if isinstance(last, dict) and "sources" in last: + if len(last["sources"]) >= 2: + return { + "type": "stop", + "result": last + } + + # if weak results, deepen search + return { + "type": "tool", + "tool": "research", + "input": { + "query": goal, + "depth": 2, + "max_sources": 5 + } + } + + return { + "type": "stop", + "result": last + } + + # ========================= + # TOOL EXECUTION + # ========================= + + def _execute(self, action: dict[str, Any], state: dict[str, Any]) -> Any: + tool_name = action.get("tool") + tool_input = action.get("input", {}) + + bus.log( + "AGENT", + "agent_tool_call", + "INFO", + { + "tool": tool_name, + "input": tool_input + } + ) + + if not tool_name: + return {"error": "No tool specified"} + + try: + result = registry.run( + tool_name, + tool_input, + self.ctx + ) + + return result + + except Exception as e: + return { + "error": str(e), + "tool": tool_name + } + + # ========================= + # STOPPING CONDITION + # ========================= + + def _is_satisfied(self, state: dict[str, Any]) -> bool: + """ + Simple satisfaction heuristic. + + Later upgrade: LLM-based evaluation. + """ + + memory = state["memory"] + + if not memory: + return False + + last = memory[-1] + + if isinstance(last, dict): + # if research returned structured sources, assume OK + if "sources" in last and len(last["sources"]) > 0: + return True + + if "error" in last: + return True + + return False \ No newline at end of file diff --git a/tools/bochs.py b/tools/bochs.py index e69de29..9b77ac8 100644 --- a/tools/bochs.py +++ b/tools/bochs.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +from typing import Any +from pathlib import Path + +from core.tools.base import BaseTool, ToolContext +from core.tools.registry import registry +from core.safety import safety +from core.events import bus +from core.subprocess import run_command + + +class BochsTool(BaseTool): + name = "bochs" + description = "Bochs emulator control (run, validate, debug, config execution)" + + # ========================================================= + # EXECUTE ROUTER + # ========================================================= + + def execute(self, payload: dict[str, Any], ctx: ToolContext): + action = str(payload.get("action", "")).strip() + + bus.log( + "BOCHS", + "bochs_execute", + "INFO", + {"action": action} + ) + + match action: + case "run": + return self.run_vm(payload) + + case "validate": + return self.validate_config(payload) + + case "debug": + return self.debug_vm(payload) + + case _: + raise ValueError(f"Unknown bochs action: {action}") + + # ========================================================= + # HELPERS + # ========================================================= + + def _path(self, value: str) -> Path: + return safety.validate_path(value) + + # ========================================================= + # RUN VM + # ========================================================= + + def run_vm(self, payload: dict[str, Any]): + config = payload.get("config") + + if not isinstance(config, str): + raise ValueError("config must be string") + + config_path = self._path(config) + + if not config_path.exists(): + raise ValueError(f"Bochs config not found: {config}") + + result = run_command( + cmd=["bochs", "-f", str(config_path), "-q"], + ) + + return { + "action": "run", + "config": str(config_path), + "status": "success" if result.get("return_code") == 0 else "error", + "stdout": result.get("stdout", ""), + "stderr": result.get("stderr", "") + } + + # ========================================================= + # VALIDATE CONFIG + # ========================================================= + + def validate_config(self, payload: dict[str, Any]): + config = payload.get("config") + + if not isinstance(config, str): + raise ValueError("config must be string") + + config_path = self._path(config) + + if not config_path.exists(): + return { + "status": "error", + "error": "config file not found" + } + + # Bochs has no strict "validate" mode, so we simulate dry run parse + result = run_command( + cmd=["bochs", "-f", str(config_path), "-n"], + ) + + return { + "action": "validate", + "config": str(config_path), + "status": "ok" if result.get("return_code") == 0 else "warning", + "stdout": result.get("stdout", ""), + "stderr": result.get("stderr", "") + } + + # ========================================================= + # DEBUG MODE + # ========================================================= + + def debug_vm(self, payload: dict[str, Any]): + config = payload.get("config") + + if not isinstance(config, str): + raise ValueError("config must be string") + + config_path = self._path(config) + + if not config_path.exists(): + raise ValueError("config not found") + + result = run_command( + cmd=["bochs", "-f", str(config_path), "-q", "-debug"], + ) + + return { + "action": "debug", + "config": str(config_path), + "status": "success" if result.get("return_code") == 0 else "error", + "stdout": result.get("stdout", ""), + "stderr": result.get("stderr", "") + } + + +# ========================================================= +# REGISTER TOOL +# ========================================================= + +registry.register(BochsTool()) \ No newline at end of file diff --git a/tools/cmake.py b/tools/cmake.py index e69de29..8e6ba64 100644 --- a/tools/cmake.py +++ b/tools/cmake.py @@ -0,0 +1,188 @@ +from __future__ import annotations + +from typing import Any +from pathlib import Path + +from core.tools.base import BaseTool, ToolContext +from core.tools.registry import registry +from core.safety import safety +from core.events import bus +from core.subprocess import run_command + + +class CMakeTool(BaseTool): + name = "cmake" + description = "CMake build system operations (configure, build, generate, clean)" + + # ========================================================= + # EXECUTE ROUTER + # ========================================================= + + def execute(self, payload: dict[str, Any], ctx: ToolContext): + action = str(payload.get("action", "")).strip() + + bus.log( + "CMAKE", + "cmake_execute", + "INFO", + {"action": action} + ) + + match action: + case "configure": + return self.configure(payload) + + case "build": + return self.build(payload) + + case "clean": + return self.clean(payload) + + case "generate": + return self.generate(payload) + + case _: + raise ValueError(f"Unknown cmake action: {action}") + + # ========================================================= + # HELPERS + # ========================================================= + + def _path(self, value: str) -> Path: + return safety.validate_path(value) + + # ========================================================= + # CONFIGURE + # ========================================================= + + def configure(self, payload: dict[str, Any]): + source_dir = payload.get("source_dir", ".") + build_dir = payload.get("build_dir", "build") + generator = payload.get("generator") # optional + build_type = payload.get("build_type", "Release") + + if not isinstance(source_dir, str): + raise ValueError("source_dir must be string") + if not isinstance(build_dir, str): + raise ValueError("build_dir must be string") + + src = self._path(source_dir) + bld = self._path(build_dir) + + bld.mkdir(parents=True, exist_ok=True) + + cmd = [ + "cmake", + "-S", str(src), + "-B", str(bld), + f"-DCMAKE_BUILD_TYPE={build_type}" + ] + + if generator: + if not isinstance(generator, str): + raise ValueError("generator must be string") + cmd.extend(["-G", generator]) + + result = run_command(cmd=cmd) + + return { + "action": "configure", + "source_dir": str(src), + "build_dir": str(bld), + "status": "success" if result.get("return_code") == 0 else "error", + "stdout": result.get("stdout", ""), + "stderr": result.get("stderr", "") + } + + # ========================================================= + # BUILD + # ========================================================= + + def build(self, payload: dict[str, Any]): + build_dir = payload.get("build_dir", "build") + target = payload.get("target") # optional + jobs = payload.get("jobs", 0) + + if not isinstance(build_dir, str): + raise ValueError("build_dir must be string") + + bld = self._path(build_dir) + + cmd = ["cmake", "--build", str(bld)] + + if target: + if not isinstance(target, str): + raise ValueError("target must be string") + cmd.extend(["--target", target]) + + if isinstance(jobs, int) and jobs > 0: + cmd.extend(["--parallel", str(jobs)]) + + result = run_command(cmd=cmd) + + return { + "action": "build", + "build_dir": str(bld), + "target": target, + "status": "success" if result.get("return_code") == 0 else "error", + "stdout": result.get("stdout", ""), + "stderr": result.get("stderr", "") + } + + # ========================================================= + # GENERATE (alias convenience) + # ========================================================= + + def generate(self, payload: dict[str, Any]): + # CMake modern workflow usually doesn't need this separately, + # but kept for explicit "generate-only" workflows. + return self.configure(payload) + + # ========================================================= + # CLEAN + # ========================================================= + + def clean(self, payload: dict[str, Any]): + build_dir = payload.get("build_dir", "build") + + if not isinstance(build_dir, str): + raise ValueError("build_dir must be string") + + bld = self._path(build_dir) + + if not bld.exists(): + return { + "action": "clean", + "status": "skipped", + "message": "build directory does not exist" + } + + # safe clean: only remove cache artifacts, not full directory unless requested + cache_file = bld / "CMakeCache.txt" + + removed = [] + + if cache_file.exists(): + cache_file.unlink() + removed.append("CMakeCache.txt") + + # optional: remove CMakeFiles + cmake_files = bld / "CMakeFiles" + if cmake_files.exists(): + import shutil + shutil.rmtree(cmake_files) + removed.append("CMakeFiles/") + + return { + "action": "clean", + "build_dir": str(bld), + "removed": removed, + "status": "ok" + } + + +# ========================================================= +# REGISTER +# ========================================================= + +registry.register(CMakeTool()) \ No newline at end of file diff --git a/tools/crawler.py b/tools/crawler.py new file mode 100644 index 0000000..3ab6cf3 --- /dev/null +++ b/tools/crawler.py @@ -0,0 +1,194 @@ +from __future__ import annotations + +from typing import Any +from urllib.parse import urljoin, urlparse +import urllib.request +import re + +from core.tools.base import BaseTool, ToolContext +from core.tools.registry import registry +from core.events import bus + + +class CrawlerTool(BaseTool): + """ + Lightweight safe web crawler. + + Designed for: + - page fetching + - link extraction + - basic text scraping + - bounded crawling + """ + + name = "crawler" + description = "Fetch and crawl web pages safely" + + # ========================= + # EXECUTE + # ========================= + + def execute(self, payload: dict[str, Any], ctx: ToolContext): + action = str(payload.get("action", "fetch")).strip() + + bus.log( + "CRAWLER", + "crawler_execute", + "INFO", + {"action": action} + ) + + match action: + case "fetch": + return self.fetch(payload) + + case "links": + return self.extract_links(payload) + + case "crawl": + return self.crawl(payload) + + case _: + raise ValueError(f"Unknown crawler action: {action}") + + # ========================= + # FETCH PAGE + # ========================= + + def fetch(self, payload: dict[str, Any]): + url = payload.get("url") + + if not isinstance(url, str): + raise ValueError("url must be string") + + req = urllib.request.Request( + url, + headers={"User-Agent": "MCP-Crawler/1.0"} + ) + + try: + with urllib.request.urlopen(req, timeout=6) as resp: + html = resp.read().decode("utf-8", errors="ignore") + + text = self._strip_html(html) + + return { + "url": url, + "text": text[:5000], + "length": len(text) + } + + except Exception as e: + return { + "url": url, + "error": str(e) + } + + # ========================= + # EXTRACT LINKS + # ========================= + + def extract_links(self, payload: dict[str, Any]): + url = payload.get("url") + + if not isinstance(url, str): + raise ValueError("url must be string") + + try: + req = urllib.request.Request(url) + with urllib.request.urlopen(req, timeout=6) as resp: + html = resp.read().decode("utf-8", errors="ignore") + + links = re.findall(r'href=["\'](.*?)["\']', html) + + normalized = [] + for link in links: + normalized.append(urljoin(url, link)) + + return { + "url": url, + "links": normalized[:200], + "count": len(normalized) + } + + except Exception as e: + return { + "url": url, + "error": str(e) + } + + # ========================= + # SIMPLE CRAWL (DEPTH 1–2) + # ========================= + + def crawl(self, payload: dict[str, Any]): + start_url = payload.get("url") + depth = payload.get("depth", 1) + + if not isinstance(start_url, str): + raise ValueError("url must be string") + if not isinstance(depth, int): + depth = 1 + + visited = set() + results = [] + + def safe_fetch(u: str): + try: + req = urllib.request.Request(u) + with urllib.request.urlopen(req, timeout=5) as resp: + return resp.read().decode("utf-8", errors="ignore") + except Exception: + return "" + + def crawl_url(url: str, d: int): + if d < 0 or url in visited: + return + + visited.add(url) + + html = safe_fetch(url) + text = self._strip_html(html) + + results.append({ + "url": url, + "text": text[:2000] + }) + + if d == 0: + return + + links = re.findall(r'href=["\'](.*?)["\']', html) + for link in links[:20]: + full = urljoin(url, link) + + # safety: stay same domain + if urlparse(full).netloc == urlparse(start_url).netloc: + crawl_url(full, d - 1) + + crawl_url(start_url, depth) + + return { + "start": start_url, + "depth": depth, + "pages": results, + "count": len(results) + } + + # ========================= + # HTML STRIPPER + # ========================= + + def _strip_html(self, html: str) -> str: + html = re.sub(r".*?", "", html, flags=re.S) + html = re.sub(r".*?", "", html, flags=re.S) + html = re.sub(r"<.*?>", " ", html) + html = re.sub(r"\s+", " ", html) + return html.strip() + + +# ========================= +# REGISTER +# ========================= + +registry.register(CrawlerTool()) \ No newline at end of file diff --git a/tools/discovery.py b/tools/discovery.py index 33b65d5..26493c2 100644 --- a/tools/discovery.py +++ b/tools/discovery.py @@ -2,9 +2,12 @@ from __future__ import annotations import importlib import pkgutil +import logging import tools as tools_pkg +logger = logging.getLogger(__name__) + def load_all_tools(): """ @@ -12,11 +15,36 @@ def load_all_tools(): Imports all modules inside /tools so they register themselves into the registry. + + Safe version: + - isolates import failures per module + - logs instead of crashing system boot """ - for module in pkgutil.iter_modules( - tools_pkg.__path__ - ): - importlib.import_module( - f"tools.{module.name}" - ) \ No newline at end of file + loaded = 0 + failed = 0 + + for module in pkgutil.iter_modules(tools_pkg.__path__): + module_name = f"tools.{module.name}" + + try: + importlib.import_module(module_name) + loaded += 1 + + logger.info(f"[TOOLS] Loaded: {module_name}") + + except Exception as e: + failed += 1 + + logger.exception( + f"[TOOLS] Failed to load {module_name}: {e}" + ) + + logger.info( + f"[TOOLS] Discovery complete: loaded={loaded}, failed={failed}" + ) + + return { + "loaded": loaded, + "failed": failed + } \ No newline at end of file diff --git a/tools/docker.py b/tools/docker.py index e69de29..e3d3a4d 100644 --- a/tools/docker.py +++ b/tools/docker.py @@ -0,0 +1,225 @@ +from __future__ import annotations + +from typing import Any + +from core.tools.base import BaseTool, ToolContext +from core.tools.registry import registry +from core.events import bus +from core.subprocess import run_command + + +class DockerTool(BaseTool): + name = "docker" + description = "Docker container and image management" + + # ========================================================= + # EXECUTE ROUTER + # ========================================================= + + def execute(self, payload: dict[str, Any], ctx: ToolContext): + action = str(payload.get("action", "")).strip() + + bus.log( + "DOCKER", + "docker_execute", + "INFO", + {"action": action} + ) + + match action: + case "ps": + return self.ps(payload) + + case "run": + return self.run_container(payload, ctx) + + case "stop": + return self.stop_container(payload) + + case "start": + return self.start_container(payload) + + case "restart": + return self.restart_container(payload) + + case "logs": + return self.logs(payload) + + case "images": + return self.images(payload) + + case "build": + return self.build_image(payload, ctx) + + case _: + raise ValueError(f"Unknown docker action: {action}") + + # ========================================================= + # CONTAINERS LIST + # ========================================================= + + def ps(self, payload: dict[str, Any]): + all_containers = payload.get("all", False) + + cmd = ["docker", "ps"] + if all_containers: + cmd.append("-a") + + result = run_command(cmd=cmd) + + return { + "action": "ps", + "status": "ok" if result.get("return_code") == 0 else "error", + "output": result.get("stdout", ""), + "error": result.get("stderr", "") + } + + # ========================================================= + # RUN CONTAINER + # ========================================================= + + def run_container(self, payload: dict[str, Any], ctx: ToolContext): + image = payload.get("image") + name = payload.get("name") + args = payload.get("args", []) + + if not isinstance(image, str): + raise ValueError("image must be string") + + if not isinstance(args, list): + raise ValueError("args must be list") + + cmd = ["docker", "run"] + + if name: + if not isinstance(name, str): + raise ValueError("name must be string") + cmd += ["--name", name] + + cmd += args + cmd.append(image) + + if ctx.dry_run: + return { + "dry_run": True, + "command": cmd + } + + result = run_command(cmd=cmd) + + return { + "action": "run", + "image": image, + "status": "ok" if result.get("return_code") == 0 else "error", + "stdout": result.get("stdout", ""), + "stderr": result.get("stderr", "") + } + + # ========================================================= + # STOP / START / RESTART + # ========================================================= + + def stop_container(self, payload: dict[str, Any]): + return self._simple_container_action("stop", payload) + + def start_container(self, payload: dict[str, Any]): + return self._simple_container_action("start", payload) + + def restart_container(self, payload: dict[str, Any]): + return self._simple_container_action("restart", payload) + + def _simple_container_action(self, action: str, payload: dict[str, Any]): + container = payload.get("container") + + if not isinstance(container, str): + raise ValueError("container must be string") + + result = run_command( + cmd=["docker", action, container] + ) + + return { + "action": action, + "container": container, + "status": "ok" if result.get("return_code") == 0 else "error", + "stdout": result.get("stdout", ""), + "stderr": result.get("stderr", "") + } + + # ========================================================= + # LOGS + # ========================================================= + + def logs(self, payload: dict[str, Any]): + container = payload.get("container") + tail = payload.get("tail", 100) + + if not isinstance(container, str): + raise ValueError("container must be string") + + cmd = ["docker", "logs", "--tail", str(tail), container] + + result = run_command(cmd=cmd) + + return { + "action": "logs", + "container": container, + "output": result.get("stdout", ""), + "error": result.get("stderr", "") + } + + # ========================================================= + # IMAGES + # ========================================================= + + def images(self, payload: dict[str, Any]): + result = run_command(cmd=["docker", "images"]) + + return { + "action": "images", + "output": result.get("stdout", ""), + "error": result.get("stderr", "") + } + + # ========================================================= + # BUILD IMAGE + # ========================================================= + + def build_image(self, payload: dict[str, Any], ctx: ToolContext): + path = payload.get("path", ".") + tag = payload.get("tag") + + if not isinstance(path, str): + raise ValueError("path must be string") + + cmd = ["docker", "build", "-t"] + + if isinstance(tag, str): + cmd.append(tag) + else: + cmd.append("untagged-image") + + cmd.append(path) + + if ctx.dry_run: + return { + "dry_run": True, + "command": cmd + } + + result = run_command(cmd=cmd) + + return { + "action": "build", + "path": path, + "status": "ok" if result.get("return_code") == 0 else "error", + "stdout": result.get("stdout", ""), + "stderr": result.get("stderr", "") + } + + +# ========================================================= +# REGISTER TOOL +# ========================================================= + +registry.register(DockerTool()) \ No newline at end of file diff --git a/tools/ffmpeg.py b/tools/ffmpeg.py index e69de29..1854018 100644 --- a/tools/ffmpeg.py +++ b/tools/ffmpeg.py @@ -0,0 +1,254 @@ +from __future__ import annotations + +from typing import Any + +from core.tools.base import BaseTool, ToolContext +from core.tools.registry import registry +from core.events import bus +from core.subprocess import run_command + + +class FFmpegTool(BaseTool): + name = "ffmpeg" + description = "Media processing using FFmpeg" + + # ========================================================= + # ROUTER + # ========================================================= + + def execute(self, payload: dict[str, Any], ctx: ToolContext): + action = str(payload.get("action", "")).strip() + + bus.log( + "FFMPEG", + "ffmpeg_execute", + "INFO", + {"action": action} + ) + + match action: + case "convert": + return self.convert(payload, ctx) + + case "extract_audio": + return self.extract_audio(payload, ctx) + + case "trim": + return self.trim(payload, ctx) + + case "merge": + return self.merge(payload, ctx) + + case "probe": + return self.probe(payload) + + case "thumbnail": + return self.thumbnail(payload, ctx) + + case _: + raise ValueError(f"Unknown ffmpeg action: {action}") + + # ========================================================= + # CONVERT + # ========================================================= + + def convert(self, payload: dict[str, Any], ctx: ToolContext): + input_file = payload.get("input") + output_file = payload.get("output") + codec = payload.get("codec") # optional + + if not isinstance(input_file, str) or not isinstance(output_file, str): + raise ValueError("input/output must be strings") + + cmd = ["ffmpeg", "-y", "-i", input_file] + + if isinstance(codec, str): + cmd += ["-c:v", codec] + + cmd.append(output_file) + + if ctx.dry_run: + return {"dry_run": True, "command": cmd} + + result = run_command(cmd=cmd) + + return { + "action": "convert", + "status": "ok" if result.get("return_code") == 0 else "error", + "stdout": result.get("stdout", ""), + "stderr": result.get("stderr", "") + } + + # ========================================================= + # EXTRACT AUDIO + # ========================================================= + + def extract_audio(self, payload: dict[str, Any], ctx: ToolContext): + input_file = payload.get("input") + output_file = payload.get("output") + + if not isinstance(input_file, str) or not isinstance(output_file, str): + raise ValueError("input/output must be strings") + + cmd = [ + "ffmpeg", + "-y", + "-i", input_file, + "-vn", + "-acodec", "copy", + output_file + ] + + if ctx.dry_run: + return {"dry_run": True, "command": cmd} + + result = run_command(cmd=cmd) + + return { + "action": "extract_audio", + "status": "ok" if result.get("return_code") == 0 else "error", + "stdout": result.get("stdout", ""), + "stderr": result.get("stderr", "") + } + + # ========================================================= + # TRIM + # ========================================================= + + def trim(self, payload: dict[str, Any], ctx: ToolContext): + input_file = payload.get("input") + output_file = payload.get("output") + start = payload.get("start", "00:00:00") + duration = payload.get("duration") + + if not isinstance(input_file, str) or not isinstance(output_file, str): + raise ValueError("input/output must be strings") + + cmd = [ + "ffmpeg", + "-y", + "-i", input_file, + "-ss", str(start), + ] + + if duration: + cmd += ["-t", str(duration)] + + cmd.append(output_file) + + if ctx.dry_run: + return {"dry_run": True, "command": cmd} + + result = run_command(cmd=cmd) + + return { + "action": "trim", + "status": "ok" if result.get("return_code") == 0 else "error", + "stdout": result.get("stdout", ""), + "stderr": result.get("stderr", "") + } + + # ========================================================= + # MERGE FILES + # ========================================================= + + def merge(self, payload: dict[str, Any], ctx: ToolContext): + inputs = payload.get("inputs") + output = payload.get("output") + + if not isinstance(inputs, list) or not isinstance(output, str): + raise ValueError("inputs must be list, output must be string") + + # ffmpeg concat demuxer style + file_list = "ffmpeg_concat.txt" + with open(file_list, "w", encoding="utf-8") as f: + for item in inputs: + f.write(f"file '{item}'\n") + + cmd = [ + "ffmpeg", + "-y", + "-f", "concat", + "-safe", "0", + "-i", file_list, + "-c", "copy", + output + ] + + if ctx.dry_run: + return {"dry_run": True, "command": cmd} + + result = run_command(cmd=cmd) + + return { + "action": "merge", + "status": "ok" if result.get("return_code") == 0 else "error", + "stdout": result.get("stdout", ""), + "stderr": result.get("stderr", "") + } + + # ========================================================= + # PROBE + # ========================================================= + + def probe(self, payload: dict[str, Any]): + input_file = payload.get("input") + + if not isinstance(input_file, str): + raise ValueError("input must be string") + + cmd = [ + "ffprobe", + "-v", "error", + "-show_format", + "-show_streams", + input_file + ] + + result = run_command(cmd=cmd) + + return { + "action": "probe", + "data": result.get("stdout", ""), + "error": result.get("stderr", "") + } + + # ========================================================= + # THUMBNAIL + # ========================================================= + + def thumbnail(self, payload: dict[str, Any], ctx: ToolContext): + input_file = payload.get("input") + output_file = payload.get("output") + time = payload.get("time", "00:00:01") + + if not isinstance(input_file, str) or not isinstance(output_file, str): + raise ValueError("input/output must be strings") + + cmd = [ + "ffmpeg", + "-y", + "-ss", str(time), + "-i", input_file, + "-vframes", "1", + output_file + ] + + if ctx.dry_run: + return {"dry_run": True, "command": cmd} + + result = run_command(cmd=cmd) + + return { + "action": "thumbnail", + "status": "ok" if result.get("return_code") == 0 else "error", + "stdout": result.get("stdout", ""), + "stderr": result.get("stderr", "") + } + + +# ========================================================= +# REGISTER +# ========================================================= + +registry.register(FFmpegTool()) \ No newline at end of file diff --git a/tools/filesystem.py b/tools/filesystem.py index 92130c8..c304473 100644 --- a/tools/filesystem.py +++ b/tools/filesystem.py @@ -13,146 +13,110 @@ class FilesystemTool(BaseTool): name = "filesystem" description = "Safe filesystem operations" + MAX_READ_BYTES = 5_000_000 + MAX_LIST_ENTRIES = 5000 + # ========================= - # EXECUTE + # EXECUTE ROUTER # ========================= - def execute( - self, - payload: dict[str, Any], - ctx: ToolContext - ): + def execute(self, payload: dict[str, Any], ctx: ToolContext): action = str(payload.get("action", "")).strip() bus.log( "FILESYSTEM", "filesystem_execute", "INFO", - { - "action": action - } + {"action": action} ) - match action: - case "read_file": - return self.read_file(payload) + handlers = { + "read_file": self.read_file, + "write_file": self.write_file, + "list_dir": self.list_dir, + "exists": self.exists, + "mkdir": self.mkdir, + } - case "write_file": - return self.write_file(payload) + handler = handlers.get(action) + if not handler: + raise ValueError(f"Unknown filesystem action: {action}") - case "list_dir": - return self.list_dir(payload) + return handler(payload, ctx) - case "exists": - return self.exists(payload) + # ========================= + # PATH HELPERS + # ========================= - case "mkdir": - return self.mkdir(payload) + def _get_path(self, payload: dict[str, Any]) -> Path: + path_value = payload.get("path") + if not isinstance(path_value, str): + raise ValueError("path must be string") - case _: - raise ValueError( - f"Unknown filesystem action: {action}" - ) + return safety.validate_path(path_value) # ========================= # READ FILE # ========================= - def read_file( - self, - payload: dict[str, Any] - ): - path_value = payload.get("path") + def read_file(self, payload: dict[str, Any], ctx: ToolContext): + path = self._get_path(payload) - if not isinstance(path_value, str): - raise ValueError("path must be string") + data = path.read_text(encoding="utf-8") - path = safety.validate_path(path_value) + if len(data.encode("utf-8")) > self.MAX_READ_BYTES: + raise ValueError("File exceeds read size limit") - return { - "path": str(path), - "content": path.read_text( - encoding="utf-8" - ) - } + return {"path": str(path), "content": data} # ========================= # WRITE FILE # ========================= - def write_file( - self, - payload: dict[str, Any] - ): - path_value = payload.get("path") - content_value = payload.get("content") + def write_file(self, payload: dict[str, Any], ctx: ToolContext): + path = self._get_path(payload) - if not isinstance(path_value, str): - raise ValueError("path must be string") + content = payload.get("content") + if not isinstance(content, str): + raise ValueError("content must be string") - if not isinstance(content_value, str): - raise ValueError( - "content must be string" - ) + safety.check_file_write(path, content) - path = safety.validate_path(path_value) + path.parent.mkdir(parents=True, exist_ok=True) - safety.check_file_write( - path, - content_value - ) + if path.exists(): + backup = path.with_suffix(path.suffix + ".bak") + try: + backup.write_text(path.read_text(encoding="utf-8")) + except Exception: + pass - path.parent.mkdir( - parents=True, - exist_ok=True - ) - - path.write_text( - content_value, - encoding="utf-8" - ) + path.write_text(content, encoding="utf-8") return { "ok": True, "path": str(path), - "bytes_written": len( - content_value.encode("utf-8") - ) + "bytes_written": len(content.encode("utf-8")) } # ========================= # LIST DIRECTORY # ========================= - def list_dir( - self, - payload: dict[str, Any] - ): - path_value = payload.get( - "path", - "." - ) + def list_dir(self, payload: dict[str, Any], ctx: ToolContext): + path = self._get_path(payload) - if not isinstance(path_value, str): - raise ValueError( - "path must be string" - ) + entries = [] + for i, item in enumerate(path.iterdir()): + if i >= self.MAX_LIST_ENTRIES: + break - path = safety.validate_path( - path_value - ) - - entries: list[dict[str, Any]] = [] - - for item in path.iterdir(): - entries.append( - { - "name": item.name, - "path": str(item), - "is_dir": item.is_dir(), - "is_file": item.is_file() - } - ) + entries.append({ + "name": item.name, + "path": str(item), + "is_dir": item.is_dir(), + "is_file": item.is_file() + }) return { "path": str(path), @@ -164,20 +128,8 @@ class FilesystemTool(BaseTool): # EXISTS # ========================= - def exists( - self, - payload: dict[str, Any] - ): - path_value = payload.get("path") - - if not isinstance(path_value, str): - raise ValueError( - "path must be string" - ) - - path = safety.validate_path( - path_value - ) + def exists(self, payload: dict[str, Any], ctx: ToolContext): + path = self._get_path(payload) return { "path": str(path), @@ -190,25 +142,10 @@ class FilesystemTool(BaseTool): # MKDIR # ========================= - def mkdir( - self, - payload: dict[str, Any] - ): - path_value = payload.get("path") + def mkdir(self, payload: dict[str, Any], ctx: ToolContext): + path = self._get_path(payload) - if not isinstance(path_value, str): - raise ValueError( - "path must be string" - ) - - path = safety.validate_path( - path_value - ) - - path.mkdir( - parents=True, - exist_ok=True - ) + path.mkdir(parents=True, exist_ok=True) return { "ok": True, @@ -216,10 +153,4 @@ class FilesystemTool(BaseTool): } -# ========================= -# SELF REGISTER -# ========================= - -registry.register( - FilesystemTool() -) \ No newline at end of file +registry.register(FilesystemTool()) \ No newline at end of file diff --git a/tools/gitea.py b/tools/gitea.py index 8bb7c89..7c55e91 100644 --- a/tools/gitea.py +++ b/tools/gitea.py @@ -79,7 +79,7 @@ class GiteaTool(BaseTool): } try: - response = requests.post(url, json=payload_data, headers=self.headers) + response = requests.post(url, json=payload_data, headers=self.headers, timeout=(3, 10)) response.raise_for_status() return { "status": "success", @@ -97,7 +97,7 @@ class GiteaTool(BaseTool): url = f"{GITEA_URL}/api/v1/user/repos" try: - response = requests.get(url, headers=self.headers) + response = requests.get(url, headers=self.headers, timeout=(3, 10)) response.raise_for_status() repos = response.json() return { @@ -124,7 +124,7 @@ class GiteaTool(BaseTool): url = f"{GITEA_URL}/api/v1/repos/{owner}/{repo}" try: - response = requests.get(url, headers=self.headers) + response = requests.get(url, headers=self.headers, timeout=(3, 10)) response.raise_for_status() return { "status": "success", @@ -173,7 +173,7 @@ class GiteaTool(BaseTool): } try: - response = requests.post(url, json=payload_data, headers=self.headers) + response = requests.post(url, json=payload_data, headers=self.headers, timeout=(3, 10)) response.raise_for_status() return { "status": "success", @@ -204,7 +204,7 @@ class GiteaTool(BaseTool): url = f"{GITEA_URL}/api/v1/repos/{owner}/{repo}/contents/{path}" try: - response = requests.get(url, headers=self.headers) + response = requests.get(url, headers=self.headers, timeout=(3, 10)) response.raise_for_status() return { "status": "success", diff --git a/tools/gpu.py b/tools/gpu.py new file mode 100644 index 0000000..8f50d51 --- /dev/null +++ b/tools/gpu.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +from typing import Any + +from core.tools.base import BaseTool, ToolContext +from core.tools.registry import registry +from core.events import bus +from core.subprocess import run_command + + +class GPUTool(BaseTool): + """ + GPU introspection tool. + + Uses nvidia-smi when available. + """ + + name = "gpu" + description = "GPU usage, memory, and process inspection" + + # ========================================================= + # EXECUTE + # ========================================================= + + def execute(self, payload: dict[str, Any], ctx: ToolContext): + action = str(payload.get("action", "info")).strip() + + bus.log( + "GPU", + "gpu_execute", + "INFO", + {"action": action} + ) + + match action: + case "info": + return self.gpu_info() + + case "usage": + return self.gpu_usage() + + case "processes": + return self.gpu_processes() + + case "full": + return self.full_snapshot() + + case _: + raise ValueError(f"Unknown gpu action: {action}") + + # ========================================================= + # GPU INFO + # ========================================================= + + def gpu_info(self): + result = run_command( + cmd=[ + "nvidia-smi", + "--query-gpu=name,driver_version,memory.total", + "--format=csv,noheader" + ] + ) + + if result.get("return_code") != 0: + return { + "status": "error", + "error": result.get("stderr", "nvidia-smi not available") + } + + lines = result.get("stdout", "").strip().splitlines() + + gpus = [] + for line in lines: + parts = [p.strip() for p in line.split(",")] + if len(parts) >= 3: + gpus.append({ + "name": parts[0], + "driver": parts[1], + "memory_total": parts[2] + }) + + return { + "gpu_count": len(gpus), + "gpus": gpus + } + + # ========================================================= + # GPU USAGE + # ========================================================= + + def gpu_usage(self): + result = run_command( + cmd=[ + "nvidia-smi", + "--query-gpu=utilization.gpu,memory.used,memory.total,temperature.gpu", + "--format=csv,noheader,nounits" + ] + ) + + if result.get("return_code") != 0: + return { + "status": "error", + "error": result.get("stderr", "") + } + + lines = result.get("stdout", "").strip().splitlines() + + usage = [] + for line in lines: + parts = [p.strip() for p in line.split(",")] + if len(parts) >= 4: + usage.append({ + "gpu_util_percent": parts[0], + "memory_used_mb": parts[1], + "memory_total_mb": parts[2], + "temperature_c": parts[3] + }) + + return { + "gpus": usage + } + + # ========================================================= + # GPU PROCESSES + # ========================================================= + + def gpu_processes(self): + result = run_command( + cmd=[ + "nvidia-smi", + "--query-compute-apps=pid,process_name,used_memory", + "--format=csv,noheader" + ] + ) + + if result.get("return_code") != 0: + return { + "status": "error", + "error": result.get("stderr", "") + } + + lines = result.get("stdout", "").strip().splitlines() + + processes = [] + for line in lines: + parts = [p.strip() for p in line.split(",")] + if len(parts) >= 3: + processes.append({ + "pid": parts[0], + "name": parts[1], + "memory": parts[2] + }) + + return { + "count": len(processes), + "processes": processes + } + + # ========================================================= + # FULL SNAPSHOT + # ========================================================= + + def full_snapshot(self): + return { + "info": self.gpu_info(), + "usage": self.gpu_usage(), + "processes": self.gpu_processes() + } + + +# ========================================================= +# REGISTER +# ========================================================= + +registry.register(GPUTool()) \ No newline at end of file diff --git a/tools/info.py b/tools/info.py new file mode 100644 index 0000000..eb1d35e --- /dev/null +++ b/tools/info.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +from typing import Any +import inspect + +from core.tools.base import BaseTool, ToolContext +from core.tools.registry import registry +from core.events import bus + + +class InfoTool(BaseTool): + """ + Introspective tool for: + - tool discovery + - execution signature inspection + - structured payload hints + - example generation + """ + + name = "info" + description = "Get tool schemas, args, and usage hints" + + # ========================================================= + # EXECUTE + # ========================================================= + + def execute(self, payload: dict[str, Any], ctx: ToolContext): + action = payload.get("action", "list_tools") + + bus.log( + "INFO", + "info_execute", + "INFO", + {"action": action} + ) + + match action: + case "list_tools": + return self.list_tools() + + case "tool_schema": + return self.tool_schema(payload) + + case "tool_catalog": + return self.tool_catalog() + + case _: + raise ValueError(f"Unknown info action: {action}") + + # ========================================================= + # LIST TOOLS + # ========================================================= + + def list_tools(self) -> dict[str, Any]: + return { + "tools": [ + { + "name": t.name, + "description": getattr(t, "description", "") + } + for t in registry.all_tools() + ] + } + + # ========================================================= + # TOOL SCHEMA (CORE FEATURE) + # ========================================================= + + def tool_schema(self, payload: dict[str, Any]) -> dict[str, Any]: + name = payload.get("name") + + if not isinstance(name, str): + raise ValueError("name must be string") + + tool = next((t for t in registry.all_tools() if t.name == name), None) + + if not tool: + return {"error": f"Tool not found: {name}"} + + schema: dict[str, Any] = { + "name": tool.name, + "description": getattr(tool, "description", ""), + "class": tool.__class__.__name__, + "module": tool.__class__.__module__, + } + + # ===================================================== + # SAFE SIGNATURE INTROSPECTION + # ===================================================== + try: + sig = inspect.signature(tool.execute) + + params: dict[str, Any] = {} + + for pname, param in sig.parameters.items(): + if pname == "self": + continue + + params[pname] = { + "required": param.default is inspect._empty, + "default": None if param.default is inspect._empty else param.default, + "kind": str(param.kind), + "type": ( + param.annotation.__name__ + if hasattr(param.annotation, "__name__") + else str(param.annotation) + ) + } + + schema["execute_signature"] = params + + except Exception as e: + schema["execute_signature_error"] = str(e) + + # ===================================================== + # STRUCTURED HINTS (NOT STRING PARSING) + # ===================================================== + schema["common_payload_patterns"] = self._infer_patterns(tool) + schema["example_payload"] = self._generate_example_payload(tool) + + return schema + + # ========================================================= + # TOOL CATALOG + # ========================================================= + + def tool_catalog(self) -> dict[str, Any]: + return { + "tool_count": len(registry.all_tools()), + "tools": [ + { + "name": t.name, + "description": getattr(t, "description", ""), + "patterns": self._infer_patterns(t), + } + for t in registry.all_tools() + ] + } + + # ========================================================= + # PATTERN INFERENCE (CLEANED UP) + # ========================================================= + + def _infer_patterns(self, tool: BaseTool) -> list[str]: + name = tool.name.lower() + + mapping = { + "git": ["repo", "branch", "remote", "message"], + "filesystem": ["path", "content", "directory"], + "gitea": ["owner", "repo", "path", "message"], + "subprocess": ["cmd", "cwd", "timeout"], + "memory": ["entry", "query"], + "reflection": ["action"], + } + + return mapping.get(name, []) + + # ========================================================= + # EXAMPLES + # ========================================================= + + def _generate_example_payload(self, tool: BaseTool) -> dict[str, Any]: + name = tool.name.lower() + + examples = { + "git": { + "action": "status", + "repo": "." + }, + "filesystem": { + "action": "read_file", + "path": "./example.txt" + }, + "gitea": { + "action": "list_repos" + }, + "subprocess": { + "cmd": ["echo", "hello"], + "timeout": 60 + }, + "memory": { + "action": "add", + "entry": {"note": "example"} + }, + "reflection": { + "action": "reflect" + } + } + + return examples.get(name, {"action": "unknown"}) \ No newline at end of file diff --git a/tools/intelligent_search.py b/tools/intelligent_search.py new file mode 100644 index 0000000..43e420b --- /dev/null +++ b/tools/intelligent_search.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +from typing import Any +from urllib.parse import urlparse + +from core.tools.base import BaseTool, ToolContext +from core.tools.registry import registry +from core.events import bus + + +class IntelligentSearchTool(BaseTool): + """ + Intelligent wrapper over basic search results. + + Enhances: + - ranking + - deduplication + - best-result selection + """ + + name = "intelligent_search" + description = "Rerank and filter search results for best relevance" + + # ========================= + # EXECUTE + # ========================= + + def execute(self, payload: dict[str, Any], ctx: ToolContext): + action = str(payload.get("action", "rank")).strip() + + bus.log( + "SEARCH", + "intelligent_search_execute", + "INFO", + {"action": action} + ) + + match action: + case "rank": + return self.rank(payload) + + case "best": + return self.best(payload) + + case _: + raise ValueError(f"Unknown action: {action}") + + # ========================= + # RANK RESULTS + # ========================= + + def rank(self, payload: dict[str, Any]): + results = payload.get("results") + query = payload.get("query", "") + + if not isinstance(results, list): + raise ValueError("results must be list") + + scored = [] + + for r in results: + if not isinstance(r, dict): + continue + + title = r.get("title", "") + url = r.get("url", "") + + score = self._score(query, title, url) + + scored.append({ + "title": title, + "url": url, + "score": score + }) + + scored.sort(key=lambda x: x["score"], reverse=True) + + return { + "query": query, + "ranked": scored + } + + # ========================= + # BEST RESULT ONLY + # ========================= + + def best(self, payload: dict[str, Any]): + results = payload.get("results") + query = payload.get("query", "") + + if not isinstance(results, list): + raise ValueError("results must be list") + + best_item = None + best_score = -1 + + seen_domains = set() + + for r in results: + if not isinstance(r, dict): + continue + + title = r.get("title", "") + url = r.get("url", "") + + domain = self._domain(url) + + # simple dedupe + if domain in seen_domains: + continue + + seen_domains.add(domain) + + score = self._score(query, title, url) + + if score > best_score: + best_score = score + best_item = { + "title": title, + "url": url, + "score": score + } + + return { + "query": query, + "best": best_item + } + + # ========================= + # SCORING FUNCTION + # ========================= + + def _score(self, query: str, title: str, url: str) -> float: + """ + Lightweight heuristic ranking system. + + Replace later with LLM scoring if desired. + """ + + q = query.lower() + t = title.lower() + u = url.lower() + + score = 0.0 + + # keyword overlap + for word in q.split(): + if word in t: + score += 2.0 + if word in u: + score += 1.0 + + # title boost + if q in t: + score += 5.0 + + # HTTPS boost + if url.startswith("https"): + score += 0.5 + + # domain quality heuristic + domain = self._domain(url) + if domain.endswith(".edu") or domain.endswith(".org"): + score += 1.5 + + return score + + # ========================= + # DOMAIN HELPERS + # ========================= + + def _domain(self, url: str) -> str: + try: + return urlparse(url).netloc.lower() + except Exception: + return "" + + +# ========================= +# REGISTER +# ========================= + +registry.register(IntelligentSearchTool()) \ No newline at end of file diff --git a/tools/memory.py b/tools/memory.py new file mode 100644 index 0000000..2868b11 --- /dev/null +++ b/tools/memory.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +from typing import Any +from pathlib import Path +import json +import time + +from core.tools.base import BaseTool, ToolContext +from core.tools.registry import registry +from core.events import bus +from core.config import WORKSPACE_ROOT + + +class MemoryTool(BaseTool): + """ + Persistent memory store for agent experiences. + + Stores: + - research results + - tool outputs + - agent decisions + - arbitrary notes + """ + + name = "memory" + description = "Persistent memory storage and retrieval" + + def __init__(self): + self.memory_file = Path(WORKSPACE_ROOT) / "memory_store.json" + self._ensure_file() + + # ========================= + # EXECUTE + # ========================= + + def execute(self, payload: dict[str, Any], ctx: ToolContext): + action = str(payload.get("action", "add")).strip() + + bus.log( + "MEMORY", + "memory_execute", + "INFO", + {"action": action} + ) + + match action: + case "add": + return self.add(payload) + + case "search": + return self.search(payload) + + case "list": + return self.list_all() + + case "clear": + return self.clear() + + case _: + raise ValueError(f"Unknown memory action: {action}") + + # ========================= + # ADD MEMORY + # ========================= + + def add(self, payload: dict[str, Any]): + entry = payload.get("entry") + + if not isinstance(entry, dict): + raise ValueError("entry must be dict") + + memory = self._load() + + record = { + "id": len(memory) + 1, + "timestamp": time.time(), + "entry": entry + } + + memory.append(record) + self._save(memory) + + return { + "status": "ok", + "stored": record + } + + # ========================= + # SEARCH MEMORY + # ========================= + + def search(self, payload: dict[str, Any]): + query = payload.get("query", "") + + if not isinstance(query, str): + raise ValueError("query must be string") + + memory = self._load() + + results = [] + + for item in memory: + entry = item.get("entry", {}) + text_blob = json.dumps(entry).lower() + + if query.lower() in text_blob: + results.append(item) + + return { + "query": query, + "results": results, + "count": len(results) + } + + # ========================= + # LIST ALL + # ========================= + + def list_all(self): + return { + "memory": self._load() + } + + # ========================= + # CLEAR MEMORY + # ========================= + + def clear(self): + self._save([]) + return {"status": "cleared"} + + # ========================= + # STORAGE LAYER + # ========================= + + def _ensure_file(self): + self.memory_file.parent.mkdir(parents=True, exist_ok=True) + + if not self.memory_file.exists(): + self.memory_file.write_text("[]", encoding="utf-8") + + def _load(self) -> list[dict[str, Any]]: + try: + return json.loads(self.memory_file.read_text(encoding="utf-8")) + except Exception: + return [] + + def _save(self, data: list[dict[str, Any]]): + self.memory_file.write_text( + json.dumps(data, indent=2), + encoding="utf-8" + ) + + +# ========================= +# REGISTER +# ========================= + +registry.register(MemoryTool()) \ No newline at end of file diff --git a/tools/net.py b/tools/net.py new file mode 100644 index 0000000..9f83da5 --- /dev/null +++ b/tools/net.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +from typing import Any +import socket +import time +import urllib.request + +from core.tools.base import BaseTool, ToolContext +from core.tools.registry import registry +from core.events import bus + + +class NetTool(BaseTool): + """ + Network diagnostics and introspection tool. + + Provides basic connectivity checks and resolution utilities. + """ + + name = "net" + description = "Network diagnostics: DNS, ports, HTTP checks" + + # ========================================================= + # EXECUTE + # ========================================================= + + def execute(self, payload: dict[str, Any], ctx: ToolContext): + action = str(payload.get("action", "dns")).strip() + + bus.log( + "NET", + "net_execute", + "INFO", + {"action": action} + ) + + match action: + case "dns": + return self.dns_lookup(payload) + + case "port": + return self.check_port(payload) + + case "http": + return self.http_check(payload) + + case "latency": + return self.latency_test(payload) + + case _: + raise ValueError(f"Unknown net action: {action}") + + # ========================================================= + # DNS LOOKUP + # ========================================================= + + def dns_lookup(self, payload: dict[str, Any]): + host = payload.get("host") + + if not isinstance(host, str): + raise ValueError("host must be string") + + try: + ip = socket.gethostbyname(host) + + return { + "host": host, + "ip": ip + } + + except socket.gaierror as e: + return { + "host": host, + "error": str(e) + } + + # ========================================================= + # PORT CHECK + # ========================================================= + + def check_port(self, payload: dict[str, Any]): + host = payload.get("host", "127.0.0.1") + port = payload.get("port") + + if not isinstance(host, str): + raise ValueError("host must be string") + if not isinstance(port, int): + raise ValueError("port must be int") + + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(2) + + try: + result = sock.connect_ex((host, port)) + return { + "host": host, + "port": port, + "open": result == 0 + } + finally: + sock.close() + + # ========================================================= + # HTTP CHECK + # ========================================================= + + def http_check(self, payload: dict[str, Any]): + url = payload.get("url") + + if not isinstance(url, str): + raise ValueError("url must be string") + + try: + start = time.time() + + req = urllib.request.Request( + url, + method="GET" + ) + + with urllib.request.urlopen(req, timeout=5) as response: + status = response.getcode() + + return { + "url": url, + "status_code": status, + "latency_ms": round((time.time() - start) * 1000, 2) + } + + except Exception as e: + return { + "url": url, + "error": str(e) + } + + # ========================================================= + # LATENCY TEST + # ========================================================= + + def latency_test(self, payload: dict[str, Any]): + host = payload.get("host") + + if not isinstance(host, str): + raise ValueError("host must be string") + + try: + start = time.time() + socket.gethostbyname(host) + latency = (time.time() - start) * 1000 + + return { + "host": host, + "dns_latency_ms": round(latency, 2) + } + + except Exception as e: + return { + "host": host, + "error": str(e) + } + + +# ========================================================= +# REGISTER +# ========================================================= + +registry.register(NetTool()) \ No newline at end of file diff --git a/tools/ollama.py b/tools/ollama.py new file mode 100644 index 0000000..42c9791 --- /dev/null +++ b/tools/ollama.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +from typing import Any +import requests + +from core.tools.base import BaseTool, ToolContext +from core.tools.registry import registry +from core.events import bus +from core.config import OLLAMA_URL + + +class OllamaTool(BaseTool): + """ + Local LLM interface via Ollama. + + Enables the agent to call local models for reasoning, + summarization, and transformation tasks. + """ + + name = "ollama" + description = "Local LLM inference via Ollama" + + # ========================================================= + # EXECUTE + # ========================================================= + + def execute(self, payload: dict[str, Any], ctx: ToolContext): + action = str(payload.get("action", "generate")).strip() + + bus.log( + "OLLAMA", + "ollama_execute", + "INFO", + {"action": action} + ) + + match action: + case "generate": + return self.generate(payload, ctx) + + case "chat": + return self.chat(payload, ctx) + + case "models": + return self.list_models() + + case _: + raise ValueError(f"Unknown ollama action: {action}") + + # ========================================================= + # GENERATE (single prompt) + # ========================================================= + + def generate(self, payload: dict[str, Any], ctx: ToolContext): + model = payload.get("model", "llama3") + prompt = payload.get("prompt") + + if not isinstance(prompt, str): + raise ValueError("prompt must be string") + + url = f"{OLLAMA_URL}/api/generate" + + data = { + "model": model, + "prompt": prompt, + "stream": False + } + + if ctx.dry_run: + return { + "dry_run": True, + "model": model, + "prompt_preview": prompt[:200] + } + + try: + response = requests.post(url, json=data, timeout=(5, 120)) + response.raise_for_status() + + return { + "model": model, + "response": response.json().get("response", ""), + } + + except requests.exceptions.RequestException as e: + return { + "status": "error", + "error": str(e) + } + + # ========================================================= + # CHAT (multi-message style) + # ========================================================= + + def chat(self, payload: dict[str, Any], ctx: ToolContext): + model = payload.get("model", "llama3") + messages = payload.get("messages") + + if not isinstance(messages, list): + raise ValueError("messages must be list[dict]") + + url = f"{OLLAMA_URL}/api/chat" + + data = { + "model": model, + "messages": messages, + "stream": False + } + + if ctx.dry_run: + return { + "dry_run": True, + "model": model, + "message_count": len(messages) + } + + try: + response = requests.post(url, json=data, timeout=(5, 180)) + response.raise_for_status() + + return { + "model": model, + "response": response.json().get("message", {}).get("content", "") + } + + except requests.exceptions.RequestException as e: + return { + "status": "error", + "error": str(e) + } + + # ========================================================= + # LIST MODELS + # ========================================================= + + def list_models(self): + url = f"{OLLAMA_URL}/api/tags" + + try: + response = requests.get(url, timeout=(3, 10)) + response.raise_for_status() + + return { + "models": response.json().get("models", []) + } + + except requests.exceptions.RequestException as e: + return { + "status": "error", + "error": str(e) + } + + +# ========================================================= +# REGISTER TOOL +# ========================================================= + +registry.register(OllamaTool()) \ No newline at end of file diff --git a/tools/pid.py b/tools/pid.py new file mode 100644 index 0000000..827d535 --- /dev/null +++ b/tools/pid.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +from typing import Any + +from core.tools.base import BaseTool, ToolContext +from core.tools.registry import registry +from core.events import bus +from core.subprocess import run_command + + +class PidTool(BaseTool): + """ + Process inspection and management tool. + + Provides visibility into running system processes and optional control. + """ + + name = "pid" + description = "Process listing, lookup, and management" + + # ========================================================= + # EXECUTE + # ========================================================= + + def execute(self, payload: dict[str, Any], ctx: ToolContext): + action = str(payload.get("action", "list")).strip() + + bus.log( + "PID", + "pid_execute", + "INFO", + {"action": action} + ) + + match action: + case "list": + return self.list_processes(payload) + + case "find": + return self.find_process(payload) + + case "details": + return self.process_details(payload) + + case "kill": + return self.kill_process(payload, ctx) + + case _: + raise ValueError(f"Unknown pid action: {action}") + + # ========================================================= + # LIST PROCESSES + # ========================================================= + + def list_processes(self, payload: dict[str, Any]): + limit = payload.get("limit", 50) + + result = run_command( + cmd=["tasklist"], + ) + + if result.get("return_code") != 0: + return { + "status": "error", + "stderr": result.get("stderr", "") + } + + lines = result.get("stdout", "").splitlines() + + processes = [] + for line in lines[3:]: # skip header rows + parts = line.split() + if len(parts) < 2: + continue + + processes.append({ + "name": parts[0], + "pid": parts[1] + }) + + if len(processes) >= limit: + break + + return { + "count": len(processes), + "processes": processes + } + + # ========================================================= + # FIND PROCESS + # ========================================================= + + def find_process(self, payload: dict[str, Any]): + name = payload.get("name") + + if not isinstance(name, str): + raise ValueError("name must be string") + + result = run_command( + cmd=["tasklist", "/FI", f"IMAGENAME eq {name}"], + ) + + return { + "name": name, + "raw": result.get("stdout", ""), + "stderr": result.get("stderr", "") + } + + # ========================================================= + # PROCESS DETAILS + # ========================================================= + + def process_details(self, payload: dict[str, Any]): + pid = payload.get("pid") + + if not isinstance(pid, int): + raise ValueError("pid must be int") + + result = run_command( + cmd=["wmic", "process", "where", f"ProcessId={pid}", "get", "ProcessId,Name,CommandLine"], + ) + + return { + "pid": pid, + "raw": result.get("stdout", ""), + "stderr": result.get("stderr", "") + } + + # ========================================================= + # KILL PROCESS + # ========================================================= + + def kill_process(self, payload: dict[str, Any], ctx: ToolContext): + pid = payload.get("pid") + force = payload.get("force", False) + + if not isinstance(pid, int): + raise ValueError("pid must be int") + + if ctx.dry_run: + return { + "dry_run": True, + "pid": pid, + "force": force, + "message": "Would terminate process" + } + + cmd = ["taskkill", "/PID", str(pid)] + if force: + cmd.append("/F") + + result = run_command(cmd) + + return { + "pid": pid, + "status": "success" if result.get("return_code") == 0 else "error", + "stdout": result.get("stdout", ""), + "stderr": result.get("stderr", "") + } + + +# ========================================================= +# REGISTER TOOL +# ========================================================= + +registry.register(PidTool()) \ No newline at end of file diff --git a/tools/ping.py b/tools/ping.py index e6890ab..c82e777 100644 --- a/tools/ping.py +++ b/tools/ping.py @@ -1,5 +1,8 @@ from __future__ import annotations +from typing import Any +import time + from core.tools.base import BaseTool, ToolContext from core.tools.registry import registry from core.events import bus @@ -7,19 +10,13 @@ from core.events import bus class PingTool(BaseTool): name = "ping" - - # optional metadata (future MCP auto-binding) - description = "Health check" - - # ------------------------- - # EXECUTE - # ------------------------- + description = "Health check / liveness probe" def execute( self, - payload: dict[str, str], + payload: dict[str, Any], ctx: ToolContext - ): + ) -> dict[str, Any]: message = payload.get("message", "pong") bus.log( @@ -34,12 +31,9 @@ class PingTool(BaseTool): return { "status": "ok", "echo": message, - "tool": self.name + "tool": self.name, + "timestamp": time.time() } -# ========================= -# SELF REGISTER -# ========================= - registry.register(PingTool()) \ No newline at end of file diff --git a/tools/pwsh.py b/tools/pwsh.py new file mode 100644 index 0000000..21704df --- /dev/null +++ b/tools/pwsh.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +from typing import Any + +from core.tools.base import BaseTool, ToolContext +from core.tools.registry import registry +from core.events import bus +from core.subprocess import run_command + + +class PwshTool(BaseTool): + """ + PowerShell execution tool. + + Provides controlled execution of PowerShell commands and scripts. + """ + + name = "pwsh" + description = "Execute PowerShell commands safely" + + # ========================================================= + # ROUTER + # ========================================================= + + def execute(self, payload: dict[str, Any], ctx: ToolContext): + action = str(payload.get("action", "run")).strip() + + bus.log( + "PWSH", + "pwsh_execute", + "INFO", + {"action": action} + ) + + match action: + case "run": + return self.run(payload, ctx) + + case "script": + return self.run_script(payload, ctx) + + case "check": + return self.check_pwsh(payload) + + case _: + raise ValueError(f"Unknown pwsh action: {action}") + + # ========================================================= + # RUN COMMAND + # ========================================================= + + def run(self, payload: dict[str, Any], ctx: ToolContext): + command = payload.get("command") + cwd = payload.get("cwd") + + if not isinstance(command, str): + raise ValueError("command must be string") + + if cwd is not None and not isinstance(cwd, str): + raise ValueError("cwd must be string") + + cmd = ["pwsh", "-NoProfile", "-Command", command] + + if ctx.dry_run: + return { + "dry_run": True, + "command": cmd, + "cwd": cwd + } + + result = run_command( + cmd=cmd, + cwd=cwd + ) + + return { + "action": "run", + "status": "ok" if result.get("return_code") == 0 else "error", + "stdout": result.get("stdout", ""), + "stderr": result.get("stderr", "") + } + + # ========================================================= + # RUN SCRIPT + # ========================================================= + + def run_script(self, payload: dict[str, Any], ctx: ToolContext): + script = payload.get("script") + cwd = payload.get("cwd") + + if not isinstance(script, str): + raise ValueError("script must be string") + + cmd = ["pwsh", "-NoProfile", "-Command", script] + + if ctx.dry_run: + return { + "dry_run": True, + "script_preview": script[:500] + } + + result = run_command( + cmd=cmd, + cwd=cwd + ) + + return { + "action": "script", + "status": "ok" if result.get("return_code") == 0 else "error", + "stdout": result.get("stdout", ""), + "stderr": result.get("stderr", "") + } + + # ========================================================= + # CHECK POWERSHELL + # ========================================================= + + def check_pwsh(self, payload: dict[str, Any]): + """Check if PowerShell is available.""" + result = run_command( + cmd=["pwsh", "-Command", "$PSVersionTable.PSVersion"] + ) + + return { + "action": "check", + "available": result.get("return_code") == 0, + "version_output": result.get("stdout", ""), + "error": result.get("stderr", "") + } + + +# ========================================================= +# REGISTER TOOL +# ========================================================= + +registry.register(PwshTool()) \ No newline at end of file diff --git a/tools/qemu.py b/tools/qemu.py index e69de29..0fc85b1 100644 --- a/tools/qemu.py +++ b/tools/qemu.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +from typing import Any +import subprocess +import json +from pathlib import Path + +from core.tools.base import BaseTool, ToolContext +from core.tools.registry import registry +from core.safety import safety +from core.events import bus + + +VM_STATE_FILE = Path("./vm_state.json") + + +class QemuTool(BaseTool): + name = "qemu" + description = "QEMU VM lifecycle management" + + # ------------------------- + # EXECUTE ROUTER + # ------------------------- + + def execute(self, payload: dict[str, Any], ctx: ToolContext): + action = str(payload.get("action", "")).strip() + + bus.log( + "QEMU", + "qemu_execute", + "INFO", + {"action": action} + ) + + match action: + case "start": + return self.start_vm(payload, ctx) + + case "stop": + return self.stop_vm(payload, ctx) + + case "status": + return self.status(payload) + + case "list": + return self.list_vms() + + case _: + raise ValueError(f"Unknown qemu action: {action}") + + # ------------------------- + # STATE HELPERS + # ------------------------- + + def _load_state(self) -> dict[str, Any]: + if VM_STATE_FILE.exists(): + return json.loads(VM_STATE_FILE.read_text()) + return {} + + def _save_state(self, state: dict[str, Any]): + VM_STATE_FILE.write_text(json.dumps(state, indent=2)) + + # ------------------------- + # START VM + # ------------------------- + + def start_vm(self, payload: dict[str, Any], ctx: ToolContext): + name = payload.get("name") + image = payload.get("image") + + if not isinstance(name, str): + raise ValueError("name must be string") + if not isinstance(image, str): + raise ValueError("image must be string") + + image_path = safety.validate_path(image) + + state = self._load_state() + + if name in state and state[name].get("running"): + return {"status": "already_running", "name": name} + + cmd = [ + "qemu-system-x86_64", + "-m", "2048", + "-drive", f"file={image_path},format=qcow2", + "-nographic" + ] + + process = subprocess.Popen(cmd) + + state[name] = { + "pid": process.pid, + "image": str(image_path), + "running": True + } + + self._save_state(state) + + return { + "status": "started", + "name": name, + "pid": process.pid + } + + # ------------------------- + # STOP VM + # ------------------------- + + def stop_vm(self, payload: dict[str, Any], ctx: ToolContext): + name = payload.get("name") + + if not isinstance(name, str): + raise ValueError("name must be string") + + state = self._load_state() + + vm = state.get(name) + if not vm or not vm.get("running"): + return {"status": "not_running", "name": name} + + pid = vm["pid"] + + try: + subprocess.run(["kill", str(pid)], check=False) + except Exception: + pass + + vm["running"] = False + self._save_state(state) + + return {"status": "stopped", "name": name, "pid": pid} + + # ------------------------- + # STATUS + # ------------------------- + + def status(self, payload: dict[str, Any]): + name = payload.get("name") + + if not isinstance(name, str): + raise ValueError("name must be string") + + state = self._load_state() + return state.get(name, {"status": "unknown"}) + + # ------------------------- + # LIST + # ------------------------- + + def list_vms(self): + return self._load_state() + + +registry.register(QemuTool()) \ No newline at end of file diff --git a/tools/reflection.py b/tools/reflection.py new file mode 100644 index 0000000..dce6b06 --- /dev/null +++ b/tools/reflection.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +from typing import Any +import time +import json + +from core.tools.base import BaseTool, ToolContext +from core.tools.registry import registry +from core.events import bus + +from tools.memory import MemoryTool + + +class ReflectionTool(BaseTool): + """ + Analyzes stored memory and extracts insights. + """ + + name = "reflection" + description = "Analyze memory and extract insights or patterns" + + # ========================= + # EXECUTE + # ========================= + + def execute(self, payload: dict[str, Any], ctx: ToolContext): + action = str(payload.get("action", "reflect")).strip() + + bus.log( + "REFLECTION", + "reflection_execute", + "INFO", + {"action": action} + ) + + match action: + case "reflect": + return self.reflect(payload, ctx) + + case "summarize_failures": + return self.summarize_failures(payload, ctx) + + case "summarize_successes": + return self.summarize_successes(payload, ctx) + + case "detect_loops": + return self.detect_loops(payload, ctx) + + case _: + raise ValueError(f"Unknown reflection action: {action}") + + # ========================= + # CORE REFLECTION + # ========================= + + def reflect(self, payload: dict[str, Any], ctx: ToolContext): + memory_tool = self._get_memory() + memory = memory_tool._load() + + insights = [] + + for item in memory: + entry = item.get("entry", {}) + text = json.dumps(entry).lower() + + if "error" in text or "failed" in text: + insights.append({ + "type": "failure_pattern", + "id": item.get("id"), + "note": "Failure-related memory detected" + }) + + if "success" in text or "ok" in text: + insights.append({ + "type": "success_pattern", + "id": item.get("id"), + "note": "Success-related memory detected" + }) + + reflection = { + "timestamp": time.time(), + "insights": insights, + "total_memory": len(memory), + "insight_count": len(insights) + } + + # store reflection back into memory + memory_tool.add({ + "entry": { + "type": "reflection", + "data": reflection + } + }, ctx) # type: ignore + + return reflection + + # ========================= + # FAILURE ANALYSIS + # ========================= + + def summarize_failures(self, payload: dict[str, Any], ctx: ToolContext): + memory_tool = self._get_memory() + memory = memory_tool._load() + + failures = [ + item for item in memory + if "error" in json.dumps(item.get("entry", {})).lower() + or "fail" in json.dumps(item.get("entry", {})).lower() + ] + + return { + "count": len(failures), + "failures": failures + } + + # ========================= + # SUCCESS ANALYSIS + # ========================= + + def summarize_successes(self, payload: dict[str, Any], ctx: ToolContext): + memory_tool = self._get_memory() + memory = memory_tool._load() + + successes = [ + item for item in memory + if "success" in json.dumps(item.get("entry", {})).lower() + or "ok" in json.dumps(item.get("entry", {})).lower() + ] + + return { + "count": len(successes), + "successes": successes + } + + # ========================= + # LOOP DETECTION + # ========================= + + def detect_loops(self, payload: dict[str, Any], ctx: ToolContext): + memory_tool = self._get_memory() + memory = memory_tool._load() + + seen = {} + loops = [] + + for item in memory: + key = json.dumps(item.get("entry", {}), sort_keys=True) + + if key in seen: + loops.append({ + "original_id": seen[key], + "duplicate_id": item.get("id") + }) + else: + seen[key] = item.get("id") + + return { + "loop_count": len(loops), + "loops": loops + } + + # ========================= + # HELPERS + # ========================= + + def _get_memory(self) -> MemoryTool: + for tool in registry.all_tools(): + if tool.name == "memory": + return tool # type: ignore + raise RuntimeError("Memory tool not found") + + +# ========================= +# REGISTER +# ========================= + +registry.register(ReflectionTool()) \ No newline at end of file diff --git a/tools/research.py b/tools/research.py new file mode 100644 index 0000000..ff495b7 --- /dev/null +++ b/tools/research.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +from typing import Any + +from core.tools.base import BaseTool, ToolContext +from core.tools.registry import registry +from core.events import bus + + +class ResearchTool(BaseTool): + """ + High-level research orchestrator. + + Combines: + - search + - intelligent ranking + - crawling + into a structured report. + """ + + name = "research" + description = "Autonomous web research pipeline" + + # ========================= + # EXECUTE + # ========================= + + def execute(self, payload: dict[str, Any], ctx: ToolContext): + query = payload.get("query") + depth = payload.get("depth", 1) + max_sources = payload.get("max_sources", 3) + + if not isinstance(query, str): + raise ValueError("query must be string") + + bus.log( + "RESEARCH", + "research_execute", + "INFO", + { + "query": query, + "depth": depth, + "max_sources": max_sources + } + ) + + # Step 1: search + search_results = registry.run( + "search", + {"action": "search", "query": query, "limit": 10}, + ctx + ) + + results = search_results.get("results", []) + + if not results: + return { + "query": query, + "error": "No search results found" + } + + # Step 2: intelligent ranking + ranked = registry.run( + "intelligent_search", + { + "action": "rank", + "query": query, + "results": results + }, + ctx + ) + + ranked_list = ranked.get("ranked", [])[:max_sources] + + # Step 3: crawl top sources + pages = [] + + for item in ranked_list: + url = item.get("url") + + if not url: + continue + + page = registry.run( + "crawler", + { + "action": "fetch", + "url": url + }, + ctx + ) + + pages.append({ + "url": url, + "title": item.get("title"), + "text": page.get("text", ""), + "score": item.get("score", 0) + }) + + # Step 4: synthesize structure + return { + "query": query, + "sources_used": len(pages), + "sources": pages, + "summary_hint": self._build_hint(pages) + } + + # ========================= + # SIMPLE SYNTHESIS HELPER + # ========================= + + def _build_hint(self, pages: list[dict[str, Any]]) -> str: + """ + Lightweight heuristic summary hint. + + This is NOT a full LLM summary — just structure guidance. + """ + + if not pages: + return "No data available." + + topics = [] + + for p in pages: + text = p.get("text", "") + + # crude keyword extraction (lightweight, no deps) + words = text.split() + keywords = [w for w in words if len(w) > 6][:10] + + topics.append({ + "url": p.get("url"), + "keywords": keywords + }) + + return ( + "Key extracted themes per source:\n" + + "\n".join( + f"- {t['url']}: {', '.join(t['keywords'][:5])}" + for t in topics + ) + ) + + +# ========================= +# REGISTER +# ========================= + +registry.register(ResearchTool()) \ No newline at end of file diff --git a/tools/search.py b/tools/search.py new file mode 100644 index 0000000..b7054a0 --- /dev/null +++ b/tools/search.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +from typing import Any +import urllib.request +import urllib.parse +import re + +from core.tools.base import BaseTool, ToolContext +from core.tools.registry import registry +from core.events import bus + + +class SearchTool(BaseTool): + """ + Lightweight web search tool using DuckDuckGo HTML endpoint. + + Designed for: + - query → results + - agent retrieval step before crawling + """ + + name = "search" + description = "Web search (DuckDuckGo HTML scraping)" + + # ========================= + # EXECUTE + # ========================= + + def execute(self, payload: dict[str, Any], ctx: ToolContext): + action = str(payload.get("action", "search")).strip() + + bus.log( + "SEARCH", + "search_execute", + "INFO", + {"action": action} + ) + + match action: + case "search": + return self.search(payload) + + case _: + raise ValueError(f"Unknown search action: {action}") + + # ========================= + # SEARCH + # ========================= + + def search(self, payload: dict[str, Any]): + query = payload.get("query") + limit = payload.get("limit", 5) + + if not isinstance(query, str): + raise ValueError("query must be string") + if not isinstance(limit, int): + limit = 5 + + encoded = urllib.parse.quote(query) + + url = f"https://duckduckgo.com/html/?q={encoded}" + + req = urllib.request.Request( + url, + headers={ + "User-Agent": "MCP-Search/1.0" + } + ) + + try: + with urllib.request.urlopen(req, timeout=6) as resp: + html = resp.read().decode("utf-8", errors="ignore") + + results = self._parse_results(html) + + return { + "query": query, + "results": results[:limit], + "count": len(results) + } + + except Exception as e: + return { + "query": query, + "error": str(e) + } + + # ========================= + # PARSER + # ========================= + + def _parse_results(self, html: str) -> list[dict[str, Any]]: + """ + DuckDuckGo HTML parsing (lightweight heuristic). + """ + + results = [] + + # Extract result blocks + links = re.findall(r'(.*?)', html) + + for url, title in links: + clean_title = re.sub("<.*?>", "", title) + + results.append({ + "title": clean_title, + "url": url, + }) + + return results + + +# ========================= +# REGISTER +# ========================= + +registry.register(SearchTool()) \ No newline at end of file diff --git a/tools/subprocess.py b/tools/subprocess.py index 84dfae2..9a0e586 100644 --- a/tools/subprocess.py +++ b/tools/subprocess.py @@ -5,50 +5,59 @@ from typing import Any from core.subprocess import run_command from core.tools.base import BaseTool, ToolContext from core.tools.registry import registry +from core.safety import safety class SubprocessTool(BaseTool): name = "subprocess" description = "Run a subprocess command safely" - # ========================= - # EXECUTE - # ========================= - def execute( self, payload: dict[str, Any], ctx: ToolContext ): cmd = payload.get("cmd") - - if not isinstance(cmd, list): - raise ValueError( - "cmd must be list[str]" - ) - cwd = payload.get("cwd") + timeout = payload.get("timeout", 60) - if cwd is not None and not isinstance( - cwd, - str - ): - raise ValueError( - "cwd must be string" - ) + # ------------------------- + # Validate command + # ------------------------- + if not isinstance(cmd, list) or not all(isinstance(c, str) for c in cmd): + raise ValueError("cmd must be list[str]") - timeout = payload.get( - "timeout", - 60 - ) + # Optional safety: block empty commands + if not cmd: + raise ValueError("cmd cannot be empty") - if not isinstance( - timeout, - int - ): - raise ValueError( - "timeout must be int" - ) + # ------------------------- + # Validate cwd + # ------------------------- + if cwd is not None: + if not isinstance(cwd, str): + raise ValueError("cwd must be string") + + cwd_path = safety.validate_path(cwd) + cwd = str(cwd_path) + + # ------------------------- + # Validate timeout + # ------------------------- + if not isinstance(timeout, int) or timeout <= 0: + raise ValueError("timeout must be positive int") + + # ------------------------- + # Dry-run support (future-proofing) + # ------------------------- + if getattr(ctx, "dry_run", False): + return { + "dry_run": True, + "cmd": cmd, + "cwd": cwd, + "timeout": timeout, + "message": "Would execute subprocess" + } return run_command( cmd=cmd, @@ -57,10 +66,4 @@ class SubprocessTool(BaseTool): ) -# ========================= -# SELF REGISTER -# ========================= - -registry.register( - SubprocessTool() -) \ No newline at end of file +registry.register(SubprocessTool()) \ No newline at end of file diff --git a/tools/venv.py b/tools/venv.py index e69de29..e77dfe9 100644 --- a/tools/venv.py +++ b/tools/venv.py @@ -0,0 +1,188 @@ +from __future__ import annotations + +from typing import Any +import subprocess +import sys +from pathlib import Path + +from core.tools.base import BaseTool, ToolContext +from core.tools.registry import registry +from core.safety import safety +from core.events import bus + + +class VenvTool(BaseTool): + name = "venv" + description = "Python virtual environment management (create, install, run, list packages)" + + # ========================================================= + # EXECUTE ROUTER (ONLY ENTRYPOINT WITH ctx) + # ========================================================= + + def execute(self, payload: dict[str, Any], ctx: ToolContext): + action = str(payload.get("action", "")).strip() + + bus.log( + "VENV", + "venv_execute", + "INFO", + {"action": action} + ) + + match action: + case "create": + return self.create_venv(payload) + + case "install": + return self.install_package(payload) + + case "run": + return self.run_python(payload) + + case "list": + return self.list_packages(payload) + + case _: + raise ValueError(f"Unknown venv action: {action}") + + # ========================================================= + # PATH HELPERS + # ========================================================= + + def _venv_path(self, path: str) -> Path: + return safety.validate_path(path) + + def _python_bin(self, venv: Path) -> Path: + """Cross-platform python binary resolution.""" + if (venv / "bin").exists(): + return venv / "bin" / "python" + return venv / "Scripts" / "python.exe" + + def _pip_bin(self, venv: Path) -> Path: + """Cross-platform pip binary resolution.""" + if (venv / "bin").exists(): + return venv / "bin" / "pip" + return venv / "Scripts" / "pip.exe" + + # ========================================================= + # CREATE VENV + # ========================================================= + + def create_venv(self, payload: dict[str, Any]): + path = payload.get("path") + + if not isinstance(path, str): + raise ValueError("path must be string") + + venv_path = self._venv_path(path) + + if venv_path.exists(): + return { + "status": "exists", + "path": str(venv_path) + } + + subprocess.run( + [sys.executable, "-m", "venv", str(venv_path)], + check=True + ) + + return { + "status": "created", + "path": str(venv_path) + } + + # ========================================================= + # INSTALL PACKAGE + # ========================================================= + + def install_package(self, payload: dict[str, Any]): + path = payload.get("path") + package = payload.get("package") + + if not isinstance(path, str): + raise ValueError("path must be string") + + if not isinstance(package, str): + raise ValueError("package must be string") + + venv_path = self._venv_path(path) + pip = self._pip_bin(venv_path) + + if not pip.exists(): + raise ValueError("pip not found in virtual environment") + + result = subprocess.run( + [str(pip), "install", package], + capture_output=True, + text=True + ) + + return { + "status": "ok" if result.returncode == 0 else "error", + "stdout": result.stdout, + "stderr": result.stderr + } + + # ========================================================= + # RUN PYTHON CODE + # ========================================================= + + def run_python(self, payload: dict[str, Any]): + path = payload.get("path") + code = payload.get("code") + + if not isinstance(path, str): + raise ValueError("path must be string") + + if not isinstance(code, str): + raise ValueError("code must be string") + + venv_path = self._venv_path(path) + python_bin = self._python_bin(venv_path) + + if not python_bin.exists(): + raise ValueError("python executable not found in venv") + + result = subprocess.run( + [str(python_bin), "-c", code], + capture_output=True, + text=True + ) + + return { + "status": "ok" if result.returncode == 0 else "error", + "stdout": result.stdout, + "stderr": result.stderr + } + + # ========================================================= + # LIST PACKAGES + # ========================================================= + + def list_packages(self, payload: dict[str, Any]): + path = payload.get("path") + + if not isinstance(path, str): + raise ValueError("path must be string") + + venv_path = self._venv_path(path) + pip = self._pip_bin(venv_path) + + result = subprocess.run( + [str(pip), "list"], + capture_output=True, + text=True + ) + + return { + "status": "ok", + "output": result.stdout + } + + +# ========================================================= +# REGISTER TOOL +# ========================================================= + +registry.register(VenvTool()) \ No newline at end of file