Added many tools
This commit is contained in:
@@ -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
|
||||
|
||||
2
main.py
2
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}")
|
||||
|
||||
1
memory_store.json
Normal file
1
memory_store.json
Normal file
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -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",
|
||||
]
|
||||
# ------------------------------------------------------------
|
||||
# CURRENT DESIGN
|
||||
# ------------------------------------------------------------
|
||||
# Tool registration is now fully dynamic.
|
||||
# See: tools.discovery.load_all_tools()
|
||||
#
|
||||
# No explicit imports are required here.
|
||||
178
tools/agent_loop.py
Normal file
178
tools/agent_loop.py
Normal file
@@ -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
|
||||
141
tools/bochs.py
141
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())
|
||||
188
tools/cmake.py
188
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())
|
||||
194
tools/crawler.py
Normal file
194
tools/crawler.py
Normal file
@@ -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"<script.*?>.*?</script>", "", html, flags=re.S)
|
||||
html = re.sub(r"<style.*?>.*?</style>", "", html, flags=re.S)
|
||||
html = re.sub(r"<.*?>", " ", html)
|
||||
html = re.sub(r"\s+", " ", html)
|
||||
return html.strip()
|
||||
|
||||
|
||||
# =========================
|
||||
# REGISTER
|
||||
# =========================
|
||||
|
||||
registry.register(CrawlerTool())
|
||||
@@ -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}"
|
||||
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
|
||||
}
|
||||
225
tools/docker.py
225
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())
|
||||
254
tools/ffmpeg.py
254
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())
|
||||
@@ -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}
|
||||
)
|
||||
|
||||
handlers = {
|
||||
"read_file": self.read_file,
|
||||
"write_file": self.write_file,
|
||||
"list_dir": self.list_dir,
|
||||
"exists": self.exists,
|
||||
"mkdir": self.mkdir,
|
||||
}
|
||||
)
|
||||
|
||||
match action:
|
||||
case "read_file":
|
||||
return self.read_file(payload)
|
||||
handler = handlers.get(action)
|
||||
if not handler:
|
||||
raise ValueError(f"Unknown filesystem action: {action}")
|
||||
|
||||
case "write_file":
|
||||
return self.write_file(payload)
|
||||
return handler(payload, ctx)
|
||||
|
||||
case "list_dir":
|
||||
return self.list_dir(payload)
|
||||
# =========================
|
||||
# PATH HELPERS
|
||||
# =========================
|
||||
|
||||
case "exists":
|
||||
return self.exists(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 "mkdir":
|
||||
return self.mkdir(payload)
|
||||
|
||||
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(
|
||||
{
|
||||
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()
|
||||
)
|
||||
registry.register(FilesystemTool())
|
||||
@@ -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",
|
||||
|
||||
175
tools/gpu.py
Normal file
175
tools/gpu.py
Normal file
@@ -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())
|
||||
190
tools/info.py
Normal file
190
tools/info.py
Normal file
@@ -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"})
|
||||
183
tools/intelligent_search.py
Normal file
183
tools/intelligent_search.py
Normal file
@@ -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())
|
||||
159
tools/memory.py
Normal file
159
tools/memory.py
Normal file
@@ -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())
|
||||
167
tools/net.py
Normal file
167
tools/net.py
Normal file
@@ -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())
|
||||
158
tools/ollama.py
Normal file
158
tools/ollama.py
Normal file
@@ -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())
|
||||
166
tools/pid.py
Normal file
166
tools/pid.py
Normal file
@@ -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())
|
||||
@@ -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())
|
||||
136
tools/pwsh.py
Normal file
136
tools/pwsh.py
Normal file
@@ -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())
|
||||
155
tools/qemu.py
155
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())
|
||||
177
tools/reflection.py
Normal file
177
tools/reflection.py
Normal file
@@ -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())
|
||||
149
tools/research.py
Normal file
149
tools/research.py
Normal file
@@ -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())
|
||||
117
tools/search.py
Normal file
117
tools/search.py
Normal file
@@ -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'<a rel="nofollow" class="result__a" href="(.*?)".*?>(.*?)</a>', html)
|
||||
|
||||
for url, title in links:
|
||||
clean_title = re.sub("<.*?>", "", title)
|
||||
|
||||
results.append({
|
||||
"title": clean_title,
|
||||
"url": url,
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# =========================
|
||||
# REGISTER
|
||||
# =========================
|
||||
|
||||
registry.register(SearchTool())
|
||||
@@ -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()
|
||||
)
|
||||
registry.register(SubprocessTool())
|
||||
188
tools/venv.py
188
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())
|
||||
Reference in New Issue
Block a user
