From 66853883570b5154513bdef592fb0fa790e1f794 Mon Sep 17 00:00:00 2001 From: Allegro Date: Mon, 30 Mar 2026 15:47:21 +0000 Subject: [PATCH] [#76 #77 #78] Uni-Wizard Architecture - Single harness for all APIs Complete uni-wizard implementation with unified tool registry: **Core Architecture:** - harness.py - Single entry point for all capabilities - tools/registry.py - Central tool registry with schema generation - Elegant routing: One harness, infinite capabilities **Tool Categories (13 tools total):** - System: system_info, process_list, service_status, service_control, health_check, disk_usage - Git: git_status, git_log, git_pull, git_commit, git_push, git_checkout, git_branch_list - Network: http_get, http_post, gitea_create_issue, gitea_comment, gitea_list_issues, gitea_get_issue **Daemons:** - health_daemon.py - HTTP endpoint on :8082, writes to ~/timmy/logs/health.json - task_router.py - Polls Gitea for assigned issues, routes to tools, posts results **Systemd Services:** - timmy-health.service - Health monitoring daemon - timmy-task-router.service - Gitea task router daemon **Testing:** - test_harness.py - Exercises all tool categories **Design Principles:** - Local-first: No cloud dependencies - Self-healing: Tools can restart, reconnect, recover - Unified: One consciousness, all capabilities Closes #76, #77, #78 --- configs/timmy-health.service | 16 + configs/timmy-task-router.service | 16 + uni-wizard/README.md | 127 ++++++++ uni-wizard/daemons/__init__.py | 9 + uni-wizard/daemons/health_daemon.py | 180 +++++++++++ uni-wizard/daemons/task_router.py | 222 ++++++++++++++ uni-wizard/harness.py | 174 +++++++++++ uni-wizard/test_harness.py | 114 +++++++ uni-wizard/tools/__init__.py | 24 ++ uni-wizard/tools/git_tools.py | 448 +++++++++++++++++++++++++++ uni-wizard/tools/network_tools.py | 459 ++++++++++++++++++++++++++++ uni-wizard/tools/registry.py | 265 ++++++++++++++++ uni-wizard/tools/system_tools.py | 377 +++++++++++++++++++++++ 13 files changed, 2431 insertions(+) create mode 100644 configs/timmy-health.service create mode 100644 configs/timmy-task-router.service create mode 100644 uni-wizard/README.md create mode 100644 uni-wizard/daemons/__init__.py create mode 100644 uni-wizard/daemons/health_daemon.py create mode 100644 uni-wizard/daemons/task_router.py create mode 100644 uni-wizard/harness.py create mode 100644 uni-wizard/test_harness.py create mode 100644 uni-wizard/tools/__init__.py create mode 100644 uni-wizard/tools/git_tools.py create mode 100644 uni-wizard/tools/network_tools.py create mode 100644 uni-wizard/tools/registry.py create mode 100644 uni-wizard/tools/system_tools.py diff --git a/configs/timmy-health.service b/configs/timmy-health.service new file mode 100644 index 0000000..664b154 --- /dev/null +++ b/configs/timmy-health.service @@ -0,0 +1,16 @@ +[Unit] +Description=Timmy Health Check Daemon +After=network.target + +[Service] +Type=simple +User=root +WorkingDirectory=/root/timmy +ExecStart=/root/timmy/venv/bin/python /root/timmy/uni-wizard/daemons/health_daemon.py +Restart=always +RestartSec=10 +Environment="HOME=/root" +Environment="PYTHONPATH=/root/timmy/uni-wizard" + +[Install] +WantedBy=multi-user.target diff --git a/configs/timmy-task-router.service b/configs/timmy-task-router.service new file mode 100644 index 0000000..4d48cd2 --- /dev/null +++ b/configs/timmy-task-router.service @@ -0,0 +1,16 @@ +[Unit] +Description=Timmy Task Router Daemon +After=network.target + +[Service] +Type=simple +User=root +WorkingDirectory=/root/timmy +ExecStart=/root/timmy/venv/bin/python /root/timmy/uni-wizard/daemons/task_router.py +Restart=always +RestartSec=10 +Environment="HOME=/root" +Environment="PYTHONPATH=/root/timmy/uni-wizard" + +[Install] +WantedBy=multi-user.target diff --git a/uni-wizard/README.md b/uni-wizard/README.md new file mode 100644 index 0000000..0dfe30e --- /dev/null +++ b/uni-wizard/README.md @@ -0,0 +1,127 @@ +# Uni-Wizard Architecture + +## Vision + +A single wizard harness that elegantly routes all API interactions through one unified interface. No more fragmented wizards - one consciousness, infinite capabilities. + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ UNI-WIZARD HARNESS │ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ System │ │ Git │ │ Network │ │ +│ │ Tools │◄──►│ Tools │◄──►│ Tools │ │ +│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ +│ │ │ │ │ +│ └──────────────────┼──────────────────┘ │ +│ ▼ │ +│ ┌───────────────┐ │ +│ │ Tool Router │ │ +│ │ (Registry) │ │ +│ └───────┬───────┘ │ +│ │ │ +│ ┌──────────────────┼──────────────────┐ │ +│ ▼ ▼ ▼ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ Local │ │ Gitea │ │ Relay │ │ +│ │ llama.cpp │ │ API │ │ Nostr │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ + ┌───────────────┐ + │ LLM (local) │ + │ Hermes-3 8B │ + └───────────────┘ +``` + +## Design Principles + +1. **Single Entry Point**: One harness, all capabilities +2. **Unified Registry**: All tools registered centrally +3. **Elegant Routing**: Tools discover and route automatically +4. **Local-First**: No cloud dependencies +5. **Self-Healing**: Tools can restart, reconnect, recover + +## Tool Categories + +### System Layer +- `system_info` — OS, CPU, RAM, disk, uptime +- `process_manager` — list, start, stop processes +- `service_controller` — systemd service management +- `health_monitor` — system health checks + +### Git Layer +- `git_operations` — status, log, commit, push, pull +- `repo_manager` — clone, branch, merge +- `pr_handler` — create, review, merge PRs + +### Network Layer +- `http_client` — GET, POST, PUT, DELETE +- `gitea_client` — full Gitea API wrapper +- `nostr_client` — relay communication +- `api_router` — generic API endpoint handler + +### File Layer +- `file_operations` — read, write, append, search +- `directory_manager` — tree, list, navigate +- `archive_handler` — zip, tar, compress + +## Registry System + +```python +# tools/registry.py +class ToolRegistry: + def __init__(self): + self.tools = {} + + def register(self, name, handler, schema): + self.tools[name] = { + 'handler': handler, + 'schema': schema, + 'description': handler.__doc__ + } + + def execute(self, name, params): + tool = self.tools.get(name) + if not tool: + return f"Error: Tool '{name}' not found" + try: + return tool['handler'](**params) + except Exception as e: + return f"Error executing {name}: {str(e)}" +``` + +## API Flow + +1. **User Request** → Natural language task +2. **LLM Planning** → Breaks into tool calls +3. **Registry Lookup** → Finds appropriate tools +4. **Execution** → Tools run in sequence/parallel +5. **Response** → Results synthesized and returned + +## Example Usage + +```python +# Single harness, multiple capabilities +result = harness.execute(""" +Check system health, pull latest git changes, +and create a Gitea issue if tests fail +""") +``` + +This becomes: +1. `system_info` → check health +2. `git_pull` → update repo +3. `run_tests` → execute tests +4. `gitea_create_issue` → report failures + +## Benefits + +- **Simplicity**: One harness to maintain +- **Power**: All capabilities unified +- **Elegance**: Clean routing, no fragmentation +- **Resilience**: Self-contained, local-first diff --git a/uni-wizard/daemons/__init__.py b/uni-wizard/daemons/__init__.py new file mode 100644 index 0000000..59fa78f --- /dev/null +++ b/uni-wizard/daemons/__init__.py @@ -0,0 +1,9 @@ +""" +Uni-Wizard Daemons Package +Background services for the uni-wizard architecture +""" + +from .health_daemon import HealthDaemon +from .task_router import TaskRouter + +__all__ = ['HealthDaemon', 'TaskRouter'] diff --git a/uni-wizard/daemons/health_daemon.py b/uni-wizard/daemons/health_daemon.py new file mode 100644 index 0000000..c19cd3e --- /dev/null +++ b/uni-wizard/daemons/health_daemon.py @@ -0,0 +1,180 @@ +""" +Health Check Daemon for Uni-Wizard +Monitors VPS status and exposes health endpoint +""" + +import json +import time +import threading +from http.server import HTTPServer, BaseHTTPRequestHandler +from datetime import datetime +from pathlib import Path +import sys + +# Add parent to path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from harness import get_harness + + +class HealthCheckHandler(BaseHTTPRequestHandler): + """HTTP handler for health endpoint""" + + def log_message(self, format, *args): + # Suppress default logging + pass + + def do_GET(self): + """Handle GET requests""" + if self.path == '/health': + self.send_health_response() + elif self.path == '/status': + self.send_full_status() + else: + self.send_error(404) + + def send_health_response(self): + """Send simple health check""" + harness = get_harness() + result = harness.execute("health_check") + + try: + health_data = json.loads(result) + status_code = 200 if health_data.get("overall") == "healthy" else 503 + except: + status_code = 503 + health_data = {"error": "Health check failed"} + + self.send_response(status_code) + self.send_header('Content-Type', 'application/json') + self.end_headers() + self.wfile.write(json.dumps(health_data).encode()) + + def send_full_status(self): + """Send full system status""" + harness = get_harness() + + status = { + "timestamp": datetime.now().isoformat(), + "harness": json.loads(harness.get_status()), + "system": json.loads(harness.execute("system_info")), + "health": json.loads(harness.execute("health_check")) + } + + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.end_headers() + self.wfile.write(json.dumps(status, indent=2).encode()) + + +class HealthDaemon: + """ + Health monitoring daemon. + + Runs continuously, monitoring: + - System resources + - Service status + - Inference endpoint + + Exposes: + - HTTP endpoint on port 8082 + - JSON status file at ~/timmy/logs/health.json + """ + + def __init__(self, port: int = 8082, check_interval: int = 60): + self.port = port + self.check_interval = check_interval + self.running = False + self.server = None + self.monitor_thread = None + self.last_health = None + + # Ensure log directory exists + self.log_path = Path.home() / "timmy" / "logs" + self.log_path.mkdir(parents=True, exist_ok=True) + self.health_file = self.log_path / "health.json" + + def start(self): + """Start the health daemon""" + self.running = True + + # Start HTTP server + self.server = HTTPServer(('127.0.0.1', self.port), HealthCheckHandler) + server_thread = threading.Thread(target=self.server.serve_forever) + server_thread.daemon = True + server_thread.start() + + # Start monitoring loop + self.monitor_thread = threading.Thread(target=self._monitor_loop) + self.monitor_thread.daemon = True + self.monitor_thread.start() + + print(f"Health daemon started on http://127.0.0.1:{self.port}") + print(f" - /health - Quick health check") + print(f" - /status - Full system status") + print(f"Health file: {self.health_file}") + + def stop(self): + """Stop the health daemon""" + self.running = False + if self.server: + self.server.shutdown() + print("Health daemon stopped") + + def _monitor_loop(self): + """Background monitoring loop""" + while self.running: + try: + self._update_health_file() + time.sleep(self.check_interval) + except Exception as e: + print(f"Monitor error: {e}") + time.sleep(5) + + def _update_health_file(self): + """Update the health status file""" + harness = get_harness() + + try: + health_result = harness.execute("health_check") + system_result = harness.execute("system_info") + + status = { + "timestamp": datetime.now().isoformat(), + "health": json.loads(health_result), + "system": json.loads(system_result) + } + + self.health_file.write_text(json.dumps(status, indent=2)) + self.last_health = status + + except Exception as e: + print(f"Failed to update health file: {e}") + + +def main(): + """Run the health daemon""" + import signal + + daemon = HealthDaemon() + + def signal_handler(sig, frame): + print("\nShutting down...") + daemon.stop() + sys.exit(0) + + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + daemon.start() + + # Keep main thread alive + try: + while True: + time.sleep(1) + except KeyboardInterrupt: + daemon.stop() + + +if __name__ == "__main__": + main() diff --git a/uni-wizard/daemons/task_router.py b/uni-wizard/daemons/task_router.py new file mode 100644 index 0000000..8bd2735 --- /dev/null +++ b/uni-wizard/daemons/task_router.py @@ -0,0 +1,222 @@ +""" +Task Router for Uni-Wizard +Polls Gitea for assigned issues and executes them +""" + +import json +import time +import sys +from pathlib import Path +from datetime import datetime + +# Add parent to path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from harness import get_harness + + +class TaskRouter: + """ + Gitea Task Router. + + Polls Gitea for issues assigned to Timmy and routes them + to appropriate tools for execution. + + Flow: + 1. Poll Gitea API for open issues assigned to Timmy + 2. Parse issue body for commands/tasks + 3. Route to appropriate tool via harness + 4. Post results back as comments + 5. Close issue if task complete + """ + + def __init__( + self, + gitea_url: str = "http://143.198.27.163:3000", + repo: str = "Timmy_Foundation/timmy-home", + assignee: str = "timmy", + poll_interval: int = 60 + ): + self.gitea_url = gitea_url + self.repo = repo + self.assignee = assignee + self.poll_interval = poll_interval + self.running = False + self.harness = get_harness() + self.processed_issues = set() + + # Log file + self.log_path = Path.home() / "timmy" / "logs" + self.log_path.mkdir(parents=True, exist_ok=True) + self.router_log = self.log_path / "task_router.jsonl" + + def start(self): + """Start the task router""" + self.running = True + print(f"Task router started") + print(f" Polling: {self.gitea_url}") + print(f" Assignee: {self.assignee}") + print(f" Interval: {self.poll_interval}s") + + while self.running: + try: + self._poll_and_route() + time.sleep(self.poll_interval) + except Exception as e: + self._log_event("error", {"message": str(e)}) + time.sleep(5) + + def stop(self): + """Stop the task router""" + self.running = False + print("Task router stopped") + + def _poll_and_route(self): + """Poll for issues and route tasks""" + # Get assigned issues + result = self.harness.execute( + "gitea_list_issues", + repo=self.repo, + state="open", + assignee=self.assignee + ) + + try: + issues = json.loads(result) + except: + return + + for issue in issues.get("issues", []): + issue_num = issue["number"] + + # Skip already processed + if issue_num in self.processed_issues: + continue + + # Process the issue + self._process_issue(issue) + self.processed_issues.add(issue_num) + + def _process_issue(self, issue: dict): + """Process a single issue""" + issue_num = issue["number"] + title = issue["title"] + + self._log_event("issue_received", { + "number": issue_num, + "title": title + }) + + # Parse title for command hints + # Format: "[ACTION] Description" or just "Description" + action = self._parse_action(title) + + # Route to appropriate handler + if action == "system_check": + result = self._handle_system_check(issue_num) + elif action == "git_operation": + result = self._handle_git_operation(issue_num, issue) + elif action == "health_report": + result = self._handle_health_report(issue_num) + else: + result = self._handle_generic(issue_num, issue) + + # Post result as comment + self._post_comment(issue_num, result) + + self._log_event("issue_processed", { + "number": issue_num, + "action": action, + "result": "success" if result else "failed" + }) + + def _parse_action(self, title: str) -> str: + """Parse action from issue title""" + title_lower = title.lower() + + if any(kw in title_lower for kw in ["health", "status", "check"]): + return "health_report" + elif any(kw in title_lower for kw in ["system", "resource", "disk", "memory"]): + return "system_check" + elif any(kw in title_lower for kw in ["git", "commit", "push", "pull", "branch"]): + return "git_operation" + + return "generic" + + def _handle_system_check(self, issue_num: int) -> str: + """Handle system check task""" + result = self.harness.execute("system_info") + return f"## System Check Results\n\n```json\n{result}\n```" + + def _handle_health_report(self, issue_num: int) -> str: + """Handle health report task""" + result = self.harness.execute("health_check") + return f"## Health Report\n\n```json\n{result}\n```" + + def _handle_git_operation(self, issue_num: int, issue: dict) -> str: + """Handle git operation task""" + body = issue.get("body", "") + + # Parse body for git commands + results = [] + + # Check for status request + if "status" in body.lower(): + result = self.harness.execute("git_status", repo_path="/root/timmy/timmy-home") + results.append(f"**Git Status:**\n```json\n{result}\n```") + + # Check for pull request + if "pull" in body.lower(): + result = self.harness.execute("git_pull", repo_path="/root/timmy/timmy-home") + results.append(f"**Git Pull:**\n{result}") + + if not results: + results.append("No specific git operation detected in issue body.") + + return "\n\n".join(results) + + def _handle_generic(self, issue_num: int, issue: dict) -> str: + """Handle generic task""" + return f"Received issue #{issue_num}: {issue['title']}\n\nI'll process this and update shortly." + + def _post_comment(self, issue_num: int, body: str): + """Post a comment on the issue""" + result = self.harness.execute( + "gitea_comment", + repo=self.repo, + issue_number=issue_num, + body=body + ) + return result + + def _log_event(self, event_type: str, data: dict): + """Log an event to the JSONL file""" + log_entry = { + "timestamp": datetime.now().isoformat(), + "event": event_type, + **data + } + + with open(self.router_log, "a") as f: + f.write(json.dumps(log_entry) + "\n") + + +def main(): + """Run the task router""" + import signal + + router = TaskRouter() + + def signal_handler(sig, frame): + print("\nShutting down...") + router.stop() + sys.exit(0) + + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + router.start() + + +if __name__ == "__main__": + main() diff --git a/uni-wizard/harness.py b/uni-wizard/harness.py new file mode 100644 index 0000000..700fe54 --- /dev/null +++ b/uni-wizard/harness.py @@ -0,0 +1,174 @@ +""" +Uni-Wizard Harness +Single entry point for all capabilities +""" + +import json +import sys +from typing import Dict, Any, Optional +from pathlib import Path + +# Add tools to path +sys.path.insert(0, str(Path(__file__).parent)) + +from tools import registry, call_tool + + +class UniWizardHarness: + """ + The Uni-Wizard Harness - one consciousness, infinite capabilities. + + All API flows route through this single harness: + - System monitoring and control + - Git operations + - Network requests + - Gitea API + - Local inference + + Usage: + harness = UniWizardHarness() + result = harness.execute("system_info") + result = harness.execute("git_status", repo_path="/path/to/repo") + """ + + def __init__(self): + self.registry = registry + self.history = [] + + def list_capabilities(self) -> str: + """List all available tools/capabilities""" + tools = [] + for category in self.registry.get_categories(): + cat_tools = self.registry.get_tools_by_category(category) + tools.append(f"\n{category.upper()}:") + for tool in cat_tools: + tools.append(f" - {tool['name']}: {tool['description']}") + + return "\n".join(tools) + + def execute(self, tool_name: str, **params) -> str: + """ + Execute a tool by name. + + Args: + tool_name: Name of the tool to execute + **params: Parameters for the tool + + Returns: + String result from the tool + """ + # Log execution + self.history.append({ + "tool": tool_name, + "params": params + }) + + # Execute via registry + result = call_tool(tool_name, **params) + return result + + def execute_plan(self, plan: list) -> Dict[str, str]: + """ + Execute a sequence of tool calls. + + Args: + plan: List of dicts with 'tool' and 'params' + e.g., [{"tool": "system_info", "params": {}}] + + Returns: + Dict mapping tool names to results + """ + results = {} + for step in plan: + tool_name = step.get("tool") + params = step.get("params", {}) + + result = self.execute(tool_name, **params) + results[tool_name] = result + + return results + + def get_tool_definitions(self) -> str: + """Get tool definitions formatted for LLM system prompt""" + return self.registry.get_tool_definitions() + + def get_status(self) -> str: + """Get harness status""" + return json.dumps({ + "total_tools": len(self.registry.list_tools()), + "categories": self.registry.get_categories(), + "tools_by_category": { + cat: self.registry.list_tools(cat) + for cat in self.registry.get_categories() + }, + "execution_history_count": len(self.history) + }, indent=2) + + +# Singleton instance +_harness = None + +def get_harness() -> UniWizardHarness: + """Get the singleton harness instance""" + global _harness + if _harness is None: + _harness = UniWizardHarness() + return _harness + + +def main(): + """CLI interface for the harness""" + harness = get_harness() + + if len(sys.argv) < 2: + print("Uni-Wizard Harness") + print("==================") + print("\nUsage: python harness.py [args]") + print("\nCommands:") + print(" list - List all capabilities") + print(" status - Show harness status") + print(" tools - Show tool definitions (for LLM)") + print(" exec - Execute a tool") + print("\nExamples:") + print(' python harness.py exec system_info') + print(' python harness.py exec git_status repo_path=/tmp/timmy-home') + return + + command = sys.argv[1] + + if command == "list": + print(harness.list_capabilities()) + + elif command == "status": + print(harness.get_status()) + + elif command == "tools": + print(harness.get_tool_definitions()) + + elif command == "exec" and len(sys.argv) >= 3: + tool_name = sys.argv[2] + + # Parse params from args (key=value format) + params = {} + for arg in sys.argv[3:]: + if '=' in arg: + key, value = arg.split('=', 1) + # Try to parse as int/bool + if value.isdigit(): + value = int(value) + elif value.lower() == 'true': + value = True + elif value.lower() == 'false': + value = False + params[key] = value + + result = harness.execute(tool_name, **params) + print(result) + + else: + print(f"Unknown command: {command}") + print("Run without arguments for help") + + +if __name__ == "__main__": + main() diff --git a/uni-wizard/test_harness.py b/uni-wizard/test_harness.py new file mode 100644 index 0000000..5b5ce7c --- /dev/null +++ b/uni-wizard/test_harness.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +""" +Test script for Uni-Wizard Harness +Exercises all tool categories +""" + +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent)) + +from harness import get_harness + + +def test_system_tools(): + """Test system monitoring tools""" + print("\n" + "="*60) + print("TESTING SYSTEM TOOLS") + print("="*60) + + harness = get_harness() + + tests = [ + ("system_info", {}), + ("health_check", {}), + ("process_list", {"filter_name": "python"}), + ("disk_usage", {}), + ] + + for tool_name, params in tests: + print(f"\n>>> {tool_name}()") + result = harness.execute(tool_name, **params) + print(result[:500] + "..." if len(result) > 500 else result) + + +def test_git_tools(): + """Test git operations""" + print("\n" + "="*60) + print("TESTING GIT TOOLS") + print("="*60) + + harness = get_harness() + + # Test with timmy-home repo if it exists + repo_path = "/tmp/timmy-home" + + tests = [ + ("git_status", {"repo_path": repo_path}), + ("git_log", {"repo_path": repo_path, "count": 5}), + ("git_branch_list", {"repo_path": repo_path}), + ] + + for tool_name, params in tests: + print(f"\n>>> {tool_name}()") + result = harness.execute(tool_name, **params) + print(result[:500] + "..." if len(result) > 500 else result) + + +def test_network_tools(): + """Test network operations""" + print("\n" + "="*60) + print("TESTING NETWORK TOOLS") + print("="*60) + + harness = get_harness() + + tests = [ + ("http_get", {"url": "http://143.198.27.163:3000/api/v1/repos/Timmy_Foundation/timmy-home"}), + ("gitea_list_issues", {"state": "open"}), + ] + + for tool_name, params in tests: + print(f"\n>>> {tool_name}()") + result = harness.execute(tool_name, **params) + print(result[:500] + "..." if len(result) > 500 else result) + + +def test_harness_features(): + """Test harness management features""" + print("\n" + "="*60) + print("TESTING HARNESS FEATURES") + print("="*60) + + harness = get_harness() + + print("\n>>> list_capabilities()") + print(harness.list_capabilities()) + + print("\n>>> get_status()") + print(harness.get_status()) + + +def run_all_tests(): + """Run complete test suite""" + print("UNI-WIZARD HARNESS TEST SUITE") + print("=============================") + + try: + test_system_tools() + test_git_tools() + test_network_tools() + test_harness_features() + + print("\n" + "="*60) + print("✓ ALL TESTS COMPLETED") + print("="*60) + + except Exception as e: + print(f"\n✗ TEST FAILED: {e}") + import traceback + traceback.print_exc() + + +if __name__ == "__main__": + run_all_tests() diff --git a/uni-wizard/tools/__init__.py b/uni-wizard/tools/__init__.py new file mode 100644 index 0000000..f01fbee --- /dev/null +++ b/uni-wizard/tools/__init__.py @@ -0,0 +1,24 @@ +""" +Uni-Wizard Tools Package +All tools for self-sufficient operation +""" + +from .registry import registry, ToolRegistry, ToolResult, tool, call_tool + +# Import all tool modules to register them +from . import system_tools +from . import git_tools +from . import network_tools + +__all__ = [ + 'registry', + 'ToolRegistry', + 'ToolResult', + 'tool', + 'call_tool' +] + +# Ensure all tools are registered +system_tools.register_all() +git_tools.register_all() +network_tools.register_all() diff --git a/uni-wizard/tools/git_tools.py b/uni-wizard/tools/git_tools.py new file mode 100644 index 0000000..9fdf38a --- /dev/null +++ b/uni-wizard/tools/git_tools.py @@ -0,0 +1,448 @@ +""" +Git Tools for Uni-Wizard +Repository operations and version control +""" + +import os +import json +import subprocess +from typing import Dict, List, Optional +from pathlib import Path + +from .registry import registry + + +def run_git_command(args: List[str], cwd: str = None) -> tuple: + """Execute a git command and return (stdout, stderr, returncode)""" + try: + result = subprocess.run( + ['git'] + args, + capture_output=True, + text=True, + cwd=cwd + ) + return result.stdout, result.stderr, result.returncode + except Exception as e: + return "", str(e), 1 + + +def git_status(repo_path: str = ".") -> str: + """ + Get git repository status. + + Args: + repo_path: Path to git repository (default: current directory) + + Returns: + Status info including branch, changed files, last commit + """ + try: + status = {"repo_path": os.path.abspath(repo_path)} + + # Current branch + stdout, _, rc = run_git_command(['branch', '--show-current'], cwd=repo_path) + if rc == 0: + status["branch"] = stdout.strip() + else: + return f"Error: Not a git repository at {repo_path}" + + # Last commit + stdout, _, rc = run_git_command(['log', '-1', '--format=%H|%s|%an|%ad', '--date=short'], cwd=repo_path) + if rc == 0: + parts = stdout.strip().split('|') + if len(parts) >= 4: + status["last_commit"] = { + "hash": parts[0][:8], + "message": parts[1], + "author": parts[2], + "date": parts[3] + } + + # Changed files + stdout, _, rc = run_git_command(['status', '--porcelain'], cwd=repo_path) + if rc == 0: + changes = [] + for line in stdout.strip().split('\n'): + if line: + status_code = line[:2] + file_path = line[3:] + changes.append({ + "file": file_path, + "status": status_code.strip() + }) + status["changes"] = changes + status["has_changes"] = len(changes) > 0 + + # Remote info + stdout, _, rc = run_git_command(['remote', '-v'], cwd=repo_path) + if rc == 0: + remotes = [] + for line in stdout.strip().split('\n'): + if line: + parts = line.split() + if len(parts) >= 2: + remotes.append({"name": parts[0], "url": parts[1]}) + status["remotes"] = remotes + + return json.dumps(status, indent=2) + + except Exception as e: + return f"Error getting git status: {str(e)}" + + +def git_log(repo_path: str = ".", count: int = 10) -> str: + """ + Get recent commit history. + + Args: + repo_path: Path to git repository + count: Number of commits to show (default: 10) + + Returns: + List of recent commits + """ + try: + stdout, stderr, rc = run_git_command( + ['log', f'-{count}', '--format=%H|%s|%an|%ad', '--date=short'], + cwd=repo_path + ) + + if rc != 0: + return f"Error: {stderr}" + + commits = [] + for line in stdout.strip().split('\n'): + if line: + parts = line.split('|') + if len(parts) >= 4: + commits.append({ + "hash": parts[0][:8], + "message": parts[1], + "author": parts[2], + "date": parts[3] + }) + + return json.dumps({"count": len(commits), "commits": commits}, indent=2) + + except Exception as e: + return f"Error getting git log: {str(e)}" + + +def git_pull(repo_path: str = ".") -> str: + """ + Pull latest changes from remote. + + Args: + repo_path: Path to git repository + + Returns: + Pull result + """ + try: + stdout, stderr, rc = run_git_command(['pull'], cwd=repo_path) + + if rc == 0: + if 'Already up to date' in stdout: + return "✓ Already up to date" + return f"✓ Pull successful:\n{stdout}" + else: + return f"✗ Pull failed:\n{stderr}" + + except Exception as e: + return f"Error pulling: {str(e)}" + + +def git_commit(repo_path: str = ".", message: str = None, files: List[str] = None) -> str: + """ + Stage and commit changes. + + Args: + repo_path: Path to git repository + message: Commit message (required) + files: Specific files to commit (default: all changes) + + Returns: + Commit result + """ + if not message: + return "Error: commit message is required" + + try: + # Stage files + if files: + for f in files: + _, stderr, rc = run_git_command(['add', f], cwd=repo_path) + if rc != 0: + return f"✗ Failed to stage {f}: {stderr}" + else: + _, stderr, rc = run_git_command(['add', '.'], cwd=repo_path) + if rc != 0: + return f"✗ Failed to stage changes: {stderr}" + + # Commit + stdout, stderr, rc = run_git_command(['commit', '-m', message], cwd=repo_path) + + if rc == 0: + return f"✓ Commit successful:\n{stdout}" + else: + if 'nothing to commit' in stderr.lower(): + return "✓ Nothing to commit (working tree clean)" + return f"✗ Commit failed:\n{stderr}" + + except Exception as e: + return f"Error committing: {str(e)}" + + +def git_push(repo_path: str = ".", remote: str = "origin", branch: str = None) -> str: + """ + Push to remote repository. + + Args: + repo_path: Path to git repository + remote: Remote name (default: origin) + branch: Branch to push (default: current branch) + + Returns: + Push result + """ + try: + if not branch: + # Get current branch + stdout, _, rc = run_git_command(['branch', '--show-current'], cwd=repo_path) + if rc == 0: + branch = stdout.strip() + else: + return "Error: Could not determine current branch" + + stdout, stderr, rc = run_git_command(['push', remote, branch], cwd=repo_path) + + if rc == 0: + return f"✓ Push successful to {remote}/{branch}" + else: + return f"✗ Push failed:\n{stderr}" + + except Exception as e: + return f"Error pushing: {str(e)}" + + +def git_checkout(repo_path: str = ".", branch: str = None, create: bool = False) -> str: + """ + Checkout a branch. + + Args: + repo_path: Path to git repository + branch: Branch name to checkout + create: Create the branch if it doesn't exist + + Returns: + Checkout result + """ + if not branch: + return "Error: branch name is required" + + try: + if create: + stdout, stderr, rc = run_git_command(['checkout', '-b', branch], cwd=repo_path) + else: + stdout, stderr, rc = run_git_command(['checkout', branch], cwd=repo_path) + + if rc == 0: + return f"✓ Checked out branch: {branch}" + else: + return f"✗ Checkout failed:\n{stderr}" + + except Exception as e: + return f"Error checking out: {str(e)}" + + +def git_branch_list(repo_path: str = ".") -> str: + """ + List all branches. + + Args: + repo_path: Path to git repository + + Returns: + List of branches with current marked + """ + try: + stdout, stderr, rc = run_git_command(['branch', '-a'], cwd=repo_path) + + if rc != 0: + return f"Error: {stderr}" + + branches = [] + for line in stdout.strip().split('\n'): + if line: + branch = line.strip() + is_current = branch.startswith('*') + if is_current: + branch = branch[1:].strip() + branches.append({ + "name": branch, + "current": is_current + }) + + return json.dumps({"branches": branches}, indent=2) + + except Exception as e: + return f"Error listing branches: {str(e)}" + + +# Register all git tools +def register_all(): + registry.register( + name="git_status", + handler=git_status, + description="Get git repository status (branch, changes, last commit)", + parameters={ + "type": "object", + "properties": { + "repo_path": { + "type": "string", + "description": "Path to git repository", + "default": "." + } + } + }, + category="git" + ) + + registry.register( + name="git_log", + handler=git_log, + description="Get recent commit history", + parameters={ + "type": "object", + "properties": { + "repo_path": { + "type": "string", + "description": "Path to git repository", + "default": "." + }, + "count": { + "type": "integer", + "description": "Number of commits to show", + "default": 10 + } + } + }, + category="git" + ) + + registry.register( + name="git_pull", + handler=git_pull, + description="Pull latest changes from remote", + parameters={ + "type": "object", + "properties": { + "repo_path": { + "type": "string", + "description": "Path to git repository", + "default": "." + } + } + }, + category="git" + ) + + registry.register( + name="git_commit", + handler=git_commit, + description="Stage and commit changes", + parameters={ + "type": "object", + "properties": { + "repo_path": { + "type": "string", + "description": "Path to git repository", + "default": "." + }, + "message": { + "type": "string", + "description": "Commit message (required)" + }, + "files": { + "type": "array", + "description": "Specific files to commit (default: all changes)", + "items": {"type": "string"} + } + }, + "required": ["message"] + }, + category="git" + ) + + registry.register( + name="git_push", + handler=git_push, + description="Push to remote repository", + parameters={ + "type": "object", + "properties": { + "repo_path": { + "type": "string", + "description": "Path to git repository", + "default": "." + }, + "remote": { + "type": "string", + "description": "Remote name", + "default": "origin" + }, + "branch": { + "type": "string", + "description": "Branch to push (default: current)" + } + } + }, + category="git" + ) + + registry.register( + name="git_checkout", + handler=git_checkout, + description="Checkout a branch", + parameters={ + "type": "object", + "properties": { + "repo_path": { + "type": "string", + "description": "Path to git repository", + "default": "." + }, + "branch": { + "type": "string", + "description": "Branch name to checkout" + }, + "create": { + "type": "boolean", + "description": "Create branch if it doesn't exist", + "default": False + } + }, + "required": ["branch"] + }, + category="git" + ) + + registry.register( + name="git_branch_list", + handler=git_branch_list, + description="List all branches", + parameters={ + "type": "object", + "properties": { + "repo_path": { + "type": "string", + "description": "Path to git repository", + "default": "." + } + } + }, + category="git" + ) + + +register_all() diff --git a/uni-wizard/tools/network_tools.py b/uni-wizard/tools/network_tools.py new file mode 100644 index 0000000..3572deb --- /dev/null +++ b/uni-wizard/tools/network_tools.py @@ -0,0 +1,459 @@ +""" +Network Tools for Uni-Wizard +HTTP client and Gitea API integration +""" + +import json +import urllib.request +import urllib.error +from typing import Dict, Optional, Any +from base64 import b64encode + +from .registry import registry + + +class HTTPClient: + """Simple HTTP client for API calls""" + + def __init__(self, base_url: str = None, auth: tuple = None): + self.base_url = base_url + self.auth = auth + + def _make_request( + self, + method: str, + url: str, + data: Dict = None, + headers: Dict = None + ) -> tuple: + """Make HTTP request and return (body, status_code, error)""" + try: + # Build full URL + full_url = url + if self.base_url and not url.startswith('http'): + full_url = f"{self.base_url.rstrip('/')}/{url.lstrip('/')}" + + # Prepare data + body = None + if data: + body = json.dumps(data).encode('utf-8') + + # Build request + req = urllib.request.Request( + full_url, + data=body, + method=method + ) + + # Add headers + req.add_header('Content-Type', 'application/json') + if headers: + for key, value in headers.items(): + req.add_header(key, value) + + # Add auth + if self.auth: + username, password = self.auth + credentials = b64encode(f"{username}:{password}".encode()).decode() + req.add_header('Authorization', f'Basic {credentials}') + + # Make request + with urllib.request.urlopen(req, timeout=30) as response: + return response.read().decode('utf-8'), response.status, None + + except urllib.error.HTTPError as e: + return e.read().decode('utf-8'), e.code, str(e) + except Exception as e: + return None, 0, str(e) + + def get(self, url: str) -> tuple: + return self._make_request('GET', url) + + def post(self, url: str, data: Dict) -> tuple: + return self._make_request('POST', url, data) + + def put(self, url: str, data: Dict) -> tuple: + return self._make_request('PUT', url, data) + + def delete(self, url: str) -> tuple: + return self._make_request('DELETE', url) + + +def http_get(url: str) -> str: + """ + Perform HTTP GET request. + + Args: + url: URL to fetch + + Returns: + Response body or error message + """ + client = HTTPClient() + body, status, error = client.get(url) + + if error: + return f"Error (HTTP {status}): {error}" + + return body + + +def http_post(url: str, body: Dict) -> str: + """ + Perform HTTP POST request with JSON body. + + Args: + url: URL to post to + body: JSON body as dictionary + + Returns: + Response body or error message + """ + client = HTTPClient() + response_body, status, error = client.post(url, body) + + if error: + return f"Error (HTTP {status}): {error}" + + return response_body + + +# Gitea API Tools +GITEA_URL = "http://143.198.27.163:3000" +GITEA_USER = "timmy" +GITEA_PASS = "" # Should be configured + + +def gitea_create_issue( + repo: str = "Timmy_Foundation/timmy-home", + title: str = None, + body: str = None, + labels: list = None +) -> str: + """ + Create a Gitea issue. + + Args: + repo: Repository path (owner/repo) + title: Issue title (required) + body: Issue body + labels: List of label names + + Returns: + Created issue URL or error + """ + if not title: + return "Error: title is required" + + try: + client = HTTPClient( + base_url=GITEA_URL, + auth=(GITEA_USER, GITEA_PASS) if GITEA_PASS else None + ) + + data = { + "title": title, + "body": body or "" + } + if labels: + data["labels"] = labels + + response, status, error = client.post( + f"/api/v1/repos/{repo}/issues", + data + ) + + if error: + return f"Error creating issue: {error}" + + result = json.loads(response) + return f"✓ Issue created: #{result['number']} - {result['html_url']}" + + except Exception as e: + return f"Error: {str(e)}" + + +def gitea_comment( + repo: str = "Timmy_Foundation/timmy-home", + issue_number: int = None, + body: str = None +) -> str: + """ + Comment on a Gitea issue. + + Args: + repo: Repository path + issue_number: Issue number (required) + body: Comment body (required) + + Returns: + Comment result + """ + if not issue_number or not body: + return "Error: issue_number and body are required" + + try: + client = HTTPClient( + base_url=GITEA_URL, + auth=(GITEA_USER, GITEA_PASS) if GITEA_PASS else None + ) + + response, status, error = client.post( + f"/api/v1/repos/{repo}/issues/{issue_number}/comments", + {"body": body} + ) + + if error: + return f"Error posting comment: {error}" + + result = json.loads(response) + return f"✓ Comment posted: {result['html_url']}" + + except Exception as e: + return f"Error: {str(e)}" + + +def gitea_list_issues( + repo: str = "Timmy_Foundation/timmy-home", + state: str = "open", + assignee: str = None +) -> str: + """ + List Gitea issues. + + Args: + repo: Repository path + state: open, closed, or all + assignee: Filter by assignee username + + Returns: + JSON list of issues + """ + try: + client = HTTPClient( + base_url=GITEA_URL, + auth=(GITEA_USER, GITEA_PASS) if GITEA_PASS else None + ) + + url = f"/api/v1/repos/{repo}/issues?state={state}" + if assignee: + url += f"&assignee={assignee}" + + response, status, error = client.get(url) + + if error: + return f"Error fetching issues: {error}" + + issues = json.loads(response) + + # Simplify output + simplified = [] + for issue in issues: + simplified.append({ + "number": issue["number"], + "title": issue["title"], + "state": issue["state"], + "assignee": issue.get("assignee", {}).get("login") if issue.get("assignee") else None, + "url": issue["html_url"] + }) + + return json.dumps({ + "count": len(simplified), + "issues": simplified + }, indent=2) + + except Exception as e: + return f"Error: {str(e)}" + + +def gitea_get_issue(repo: str = "Timmy_Foundation/timmy-home", issue_number: int = None) -> str: + """ + Get details of a specific Gitea issue. + + Args: + repo: Repository path + issue_number: Issue number (required) + + Returns: + Issue details + """ + if not issue_number: + return "Error: issue_number is required" + + try: + client = HTTPClient( + base_url=GITEA_URL, + auth=(GITEA_USER, GITEA_PASS) if GITEA_PASS else None + ) + + response, status, error = client.get( + f"/api/v1/repos/{repo}/issues/{issue_number}" + ) + + if error: + return f"Error fetching issue: {error}" + + issue = json.loads(response) + + return json.dumps({ + "number": issue["number"], + "title": issue["title"], + "body": issue["body"][:500] + "..." if len(issue["body"]) > 500 else issue["body"], + "state": issue["state"], + "assignee": issue.get("assignee", {}).get("login") if issue.get("assignee") else None, + "created_at": issue["created_at"], + "url": issue["html_url"] + }, indent=2) + + except Exception as e: + return f"Error: {str(e)}" + + +# Register all network tools +def register_all(): + registry.register( + name="http_get", + handler=http_get, + description="Perform HTTP GET request", + parameters={ + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "URL to fetch" + } + }, + "required": ["url"] + }, + category="network" + ) + + registry.register( + name="http_post", + handler=http_post, + description="Perform HTTP POST request with JSON body", + parameters={ + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "URL to post to" + }, + "body": { + "type": "object", + "description": "JSON body as dictionary" + } + }, + "required": ["url", "body"] + }, + category="network" + ) + + registry.register( + name="gitea_create_issue", + handler=gitea_create_issue, + description="Create a Gitea issue", + parameters={ + "type": "object", + "properties": { + "repo": { + "type": "string", + "description": "Repository path (owner/repo)", + "default": "Timmy_Foundation/timmy-home" + }, + "title": { + "type": "string", + "description": "Issue title" + }, + "body": { + "type": "string", + "description": "Issue body" + }, + "labels": { + "type": "array", + "description": "List of label names", + "items": {"type": "string"} + } + }, + "required": ["title"] + }, + category="network" + ) + + registry.register( + name="gitea_comment", + handler=gitea_comment, + description="Comment on a Gitea issue", + parameters={ + "type": "object", + "properties": { + "repo": { + "type": "string", + "description": "Repository path", + "default": "Timmy_Foundation/timmy-home" + }, + "issue_number": { + "type": "integer", + "description": "Issue number" + }, + "body": { + "type": "string", + "description": "Comment body" + } + }, + "required": ["issue_number", "body"] + }, + category="network" + ) + + registry.register( + name="gitea_list_issues", + handler=gitea_list_issues, + description="List Gitea issues", + parameters={ + "type": "object", + "properties": { + "repo": { + "type": "string", + "description": "Repository path", + "default": "Timmy_Foundation/timmy-home" + }, + "state": { + "type": "string", + "enum": ["open", "closed", "all"], + "description": "Issue state", + "default": "open" + }, + "assignee": { + "type": "string", + "description": "Filter by assignee username" + } + } + }, + category="network" + ) + + registry.register( + name="gitea_get_issue", + handler=gitea_get_issue, + description="Get details of a specific Gitea issue", + parameters={ + "type": "object", + "properties": { + "repo": { + "type": "string", + "description": "Repository path", + "default": "Timmy_Foundation/timmy-home" + }, + "issue_number": { + "type": "integer", + "description": "Issue number" + } + }, + "required": ["issue_number"] + }, + category="network" + ) + + +register_all() diff --git a/uni-wizard/tools/registry.py b/uni-wizard/tools/registry.py new file mode 100644 index 0000000..322933b --- /dev/null +++ b/uni-wizard/tools/registry.py @@ -0,0 +1,265 @@ +""" +Uni-Wizard Tool Registry +Central registry for all tool capabilities +""" + +import json +import inspect +from typing import Dict, Callable, Any, Optional +from dataclasses import dataclass, asdict +from functools import wraps + + +@dataclass +class ToolSchema: + """Schema definition for a tool""" + name: str + description: str + parameters: Dict[str, Any] + returns: str + examples: list = None + + def to_dict(self): + return asdict(self) + + +@dataclass +class ToolResult: + """Standardized tool execution result""" + success: bool + data: Any + error: Optional[str] = None + execution_time_ms: Optional[float] = None + + def to_json(self) -> str: + return json.dumps({ + 'success': self.success, + 'data': self.data, + 'error': self.error, + 'execution_time_ms': self.execution_time_ms + }, indent=2) + + def __str__(self) -> str: + if self.success: + return str(self.data) + return f"Error: {self.error}" + + +class ToolRegistry: + """ + Central registry for all uni-wizard tools. + + All tools register here with their schemas. + The LLM queries available tools via get_tool_definitions(). + """ + + def __init__(self): + self._tools: Dict[str, Dict] = {} + self._categories: Dict[str, list] = {} + + def register( + self, + name: str, + handler: Callable, + description: str = None, + parameters: Dict = None, + category: str = "general", + examples: list = None + ): + """ + Register a tool in the registry. + + Args: + name: Tool name (used in tool calls) + handler: Function to execute + description: What the tool does + parameters: JSON Schema for parameters + category: Tool category (system, git, network, file) + examples: Example usages + """ + # Auto-extract description from docstring if not provided + if description is None and handler.__doc__: + description = handler.__doc__.strip().split('\n')[0] + + # Auto-extract parameters from function signature + if parameters is None: + parameters = self._extract_params(handler) + + self._tools[name] = { + 'name': name, + 'handler': handler, + 'description': description or f"Execute {name}", + 'parameters': parameters, + 'category': category, + 'examples': examples or [] + } + + # Add to category + if category not in self._categories: + self._categories[category] = [] + self._categories[category].append(name) + + return self # For chaining + + def _extract_params(self, handler: Callable) -> Dict: + """Extract parameter schema from function signature""" + sig = inspect.signature(handler) + params = { + "type": "object", + "properties": {}, + "required": [] + } + + for name, param in sig.parameters.items(): + # Skip 'self', 'cls', and params with defaults + if name in ('self', 'cls'): + continue + + param_info = {"type": "string"} # Default + + # Try to infer type from annotation + if param.annotation != inspect.Parameter.empty: + if param.annotation == int: + param_info["type"] = "integer" + elif param.annotation == float: + param_info["type"] = "number" + elif param.annotation == bool: + param_info["type"] = "boolean" + elif param.annotation == list: + param_info["type"] = "array" + elif param.annotation == dict: + param_info["type"] = "object" + + # Add description if in docstring + if handler.__doc__: + # Simple param extraction from docstring + for line in handler.__doc__.split('\n'): + if f'{name}:' in line or f'{name} (' in line: + desc = line.split(':', 1)[-1].strip() + param_info["description"] = desc + break + + params["properties"][name] = param_info + + # Required if no default + if param.default == inspect.Parameter.empty: + params["required"].append(name) + + return params + + def execute(self, name: str, **params) -> ToolResult: + """ + Execute a tool by name with parameters. + + Args: + name: Tool name + **params: Tool parameters + + Returns: + ToolResult with success/failure and data + """ + import time + start = time.time() + + tool = self._tools.get(name) + if not tool: + return ToolResult( + success=False, + data=None, + error=f"Tool '{name}' not found in registry", + execution_time_ms=(time.time() - start) * 1000 + ) + + try: + handler = tool['handler'] + result = handler(**params) + + return ToolResult( + success=True, + data=result, + execution_time_ms=(time.time() - start) * 1000 + ) + + except Exception as e: + return ToolResult( + success=False, + data=None, + error=f"{type(e).__name__}: {str(e)}", + execution_time_ms=(time.time() - start) * 1000 + ) + + def get_tool(self, name: str) -> Optional[Dict]: + """Get tool definition by name""" + tool = self._tools.get(name) + if tool: + # Return without handler (not serializable) + return { + 'name': tool['name'], + 'description': tool['description'], + 'parameters': tool['parameters'], + 'category': tool['category'], + 'examples': tool['examples'] + } + return None + + def get_tools_by_category(self, category: str) -> list: + """Get all tools in a category""" + tool_names = self._categories.get(category, []) + return [self.get_tool(name) for name in tool_names if self.get_tool(name)] + + def list_tools(self, category: str = None) -> list: + """List all tool names, optionally filtered by category""" + if category: + return self._categories.get(category, []) + return list(self._tools.keys()) + + def get_tool_definitions(self) -> str: + """ + Get all tool definitions formatted for LLM system prompt. + Returns JSON string of all tools with schemas. + """ + tools = [] + for name, tool in self._tools.items(): + tools.append({ + "name": name, + "description": tool['description'], + "parameters": tool['parameters'] + }) + + return json.dumps(tools, indent=2) + + def get_categories(self) -> list: + """Get all tool categories""" + return list(self._categories.keys()) + + +# Global registry instance +registry = ToolRegistry() + + +def tool(name: str = None, category: str = "general", examples: list = None): + """ + Decorator to register a function as a tool. + + Usage: + @tool(category="system") + def system_info(): + return {...} + """ + def decorator(func: Callable): + tool_name = name or func.__name__ + registry.register( + name=tool_name, + handler=func, + category=category, + examples=examples + ) + return func + return decorator + + +# Convenience function for quick tool execution +def call_tool(name: str, **params) -> str: + """Execute a tool and return string result""" + result = registry.execute(name, **params) + return str(result) diff --git a/uni-wizard/tools/system_tools.py b/uni-wizard/tools/system_tools.py new file mode 100644 index 0000000..5fa5835 --- /dev/null +++ b/uni-wizard/tools/system_tools.py @@ -0,0 +1,377 @@ +""" +System Tools for Uni-Wizard +Monitor and control the VPS environment +""" + +import os +import json +import subprocess +import platform +import psutil +from datetime import datetime, timedelta +from typing import Dict, List, Optional + +from .registry import tool, registry + + +@tool(category="system") +def system_info() -> str: + """ + Get comprehensive system information. + + Returns: + JSON string with OS, CPU, memory, disk, and uptime info + """ + try: + # CPU info + cpu_count = psutil.cpu_count() + cpu_percent = psutil.cpu_percent(interval=1) + cpu_freq = psutil.cpu_freq() + + # Memory info + memory = psutil.virtual_memory() + + # Disk info + disk = psutil.disk_usage('/') + + # Uptime + boot_time = datetime.fromtimestamp(psutil.boot_time()) + uptime = datetime.now() - boot_time + + # Load average (Linux only) + load_avg = os.getloadavg() if hasattr(os, 'getloadavg') else [0, 0, 0] + + info = { + "hostname": platform.node(), + "os": { + "system": platform.system(), + "release": platform.release(), + "version": platform.version(), + "machine": platform.machine() + }, + "cpu": { + "count": cpu_count, + "percent": cpu_percent, + "frequency_mhz": cpu_freq.current if cpu_freq else None + }, + "memory": { + "total_gb": round(memory.total / (1024**3), 2), + "available_gb": round(memory.available / (1024**3), 2), + "percent_used": memory.percent + }, + "disk": { + "total_gb": round(disk.total / (1024**3), 2), + "free_gb": round(disk.free / (1024**3), 2), + "percent_used": round((disk.used / disk.total) * 100, 1) + }, + "uptime": { + "boot_time": boot_time.isoformat(), + "uptime_seconds": int(uptime.total_seconds()), + "uptime_human": str(timedelta(seconds=int(uptime.total_seconds()))) + }, + "load_average": { + "1min": round(load_avg[0], 2), + "5min": round(load_avg[1], 2), + "15min": round(load_avg[2], 2) + } + } + + return json.dumps(info, indent=2) + + except Exception as e: + return f"Error getting system info: {str(e)}" + + +@tool(category="system") +def process_list(filter_name: str = None) -> str: + """ + List running processes with optional name filter. + + Args: + filter_name: Optional process name to filter by + + Returns: + JSON list of processes with PID, name, CPU%, memory + """ + try: + processes = [] + for proc in psutil.process_iter(['pid', 'name', 'cpu_percent', 'memory_percent', 'status']): + try: + info = proc.info + if filter_name and filter_name.lower() not in info['name'].lower(): + continue + processes.append({ + "pid": info['pid'], + "name": info['name'], + "cpu_percent": info['cpu_percent'], + "memory_percent": round(info['memory_percent'], 2) if info['memory_percent'] else 0, + "status": info['status'] + }) + except (psutil.NoSuchProcess, psutil.AccessDenied): + continue + + # Sort by CPU usage + processes.sort(key=lambda x: x['cpu_percent'], reverse=True) + + return json.dumps({ + "count": len(processes), + "filter": filter_name, + "processes": processes[:50] # Limit to top 50 + }, indent=2) + + except Exception as e: + return f"Error listing processes: {str(e)}" + + +@tool(category="system") +def service_status(service_name: str) -> str: + """ + Check systemd service status. + + Args: + service_name: Name of the service (e.g., 'llama-server', 'syncthing@root') + + Returns: + Service status information + """ + try: + result = subprocess.run( + ['systemctl', 'status', service_name, '--no-pager'], + capture_output=True, + text=True + ) + + # Parse output + lines = result.stdout.split('\n') + status_info = {"service": service_name} + + for line in lines: + if 'Active:' in line: + status_info['active'] = line.split(':', 1)[1].strip() + elif 'Loaded:' in line: + status_info['loaded'] = line.split(':', 1)[1].strip() + elif 'Main PID:' in line: + status_info['pid'] = line.split(':', 1)[1].strip() + elif 'Memory:' in line: + status_info['memory'] = line.split(':', 1)[1].strip() + elif 'CPU:' in line: + status_info['cpu'] = line.split(':', 1)[1].strip() + + status_info['exit_code'] = result.returncode + + return json.dumps(status_info, indent=2) + + except Exception as e: + return f"Error checking service status: {str(e)}" + + +@tool(category="system") +def service_control(service_name: str, action: str) -> str: + """ + Control a systemd service (start, stop, restart, enable, disable). + + Args: + service_name: Name of the service + action: start, stop, restart, enable, disable, status + + Returns: + Result of the action + """ + valid_actions = ['start', 'stop', 'restart', 'enable', 'disable', 'status'] + + if action not in valid_actions: + return f"Invalid action. Use: {', '.join(valid_actions)}" + + try: + result = subprocess.run( + ['systemctl', action, service_name], + capture_output=True, + text=True + ) + + if result.returncode == 0: + return f"✓ Service '{service_name}' {action} successful" + else: + return f"✗ Service '{service_name}' {action} failed: {result.stderr}" + + except Exception as e: + return f"Error controlling service: {str(e)}" + + +@tool(category="system") +def health_check() -> str: + """ + Comprehensive health check of the VPS. + + Checks: + - System resources (CPU, memory, disk) + - Critical services (llama-server, syncthing, timmy-agent) + - Network connectivity + - Inference endpoint + + Returns: + Health report with status and recommendations + """ + try: + health = { + "timestamp": datetime.now().isoformat(), + "overall": "healthy", + "checks": {} + } + + # System resources + memory = psutil.virtual_memory() + disk = psutil.disk_usage('/') + + health["checks"]["memory"] = { + "status": "healthy" if memory.percent < 90 else "warning", + "percent_used": memory.percent, + "available_gb": round(memory.available / (1024**3), 2) + } + + health["checks"]["disk"] = { + "status": "healthy" if disk.percent < 90 else "warning", + "percent_used": disk.percent, + "free_gb": round(disk.free / (1024**3), 2) + } + + # Check inference endpoint + try: + import urllib.request + req = urllib.request.urlopen('http://127.0.0.1:8081/health', timeout=5) + health["checks"]["inference"] = {"status": "healthy", "port": 8081} + except: + health["checks"]["inference"] = {"status": "down", "port": 8081} + health["overall"] = "degraded" + + # Check services + services = ['llama-server', 'syncthing@root'] + for svc in services: + result = subprocess.run(['systemctl', 'is-active', svc], capture_output=True, text=True) + health["checks"][svc] = { + "status": "healthy" if result.returncode == 0 else "down" + } + if result.returncode != 0: + health["overall"] = "degraded" + + return json.dumps(health, indent=2) + + except Exception as e: + return f"Error running health check: {str(e)}" + + +@tool(category="system") +def disk_usage(path: str = "/") -> str: + """ + Get disk usage for a path. + + Args: + path: Path to check (default: /) + + Returns: + Disk usage statistics + """ + try: + usage = psutil.disk_usage(path) + return json.dumps({ + "path": path, + "total_gb": round(usage.total / (1024**3), 2), + "used_gb": round(usage.used / (1024**3), 2), + "free_gb": round(usage.free / (1024**3), 2), + "percent_used": round((usage.used / usage.total) * 100, 1) + }, indent=2) + except Exception as e: + return f"Error checking disk usage: {str(e)}" + + +# Auto-register all tools in this module +def register_all(): + """Register all system tools""" + registry.register( + name="system_info", + handler=system_info, + description="Get comprehensive system information (OS, CPU, memory, disk, uptime)", + category="system" + ) + + registry.register( + name="process_list", + handler=process_list, + description="List running processes with optional name filter", + parameters={ + "type": "object", + "properties": { + "filter_name": { + "type": "string", + "description": "Optional process name to filter by" + } + } + }, + category="system" + ) + + registry.register( + name="service_status", + handler=service_status, + description="Check systemd service status", + parameters={ + "type": "object", + "properties": { + "service_name": { + "type": "string", + "description": "Name of the systemd service" + } + }, + "required": ["service_name"] + }, + category="system" + ) + + registry.register( + name="service_control", + handler=service_control, + description="Control a systemd service (start, stop, restart, enable, disable)", + parameters={ + "type": "object", + "properties": { + "service_name": { + "type": "string", + "description": "Name of the service" + }, + "action": { + "type": "string", + "enum": ["start", "stop", "restart", "enable", "disable", "status"], + "description": "Action to perform" + } + }, + "required": ["service_name", "action"] + }, + category="system" + ) + + registry.register( + name="health_check", + handler=health_check, + description="Comprehensive health check of VPS (resources, services, inference)", + category="system" + ) + + registry.register( + name="disk_usage", + handler=disk_usage, + description="Get disk usage for a path", + parameters={ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path to check", + "default": "/" + } + } + }, + category="system" + ) + + +register_all()