945 lines
38 KiB
Python
945 lines
38 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Hal - Primary AI Agent for AgentDhal Framework
|
|
|
|
Hal is the main AI assistant agent providing:
|
|
- Multi-turn conversations
|
|
- Function/tool calling capabilities
|
|
- Code execution and analysis
|
|
- Team collaboration
|
|
- Memory and context management
|
|
- Customizable behavior and prompts
|
|
|
|
Based on AutoGen AssistantAgent with DarkHal-specific enhancements.
|
|
"""
|
|
|
|
import asyncio
|
|
import subprocess
|
|
import sys
|
|
import os
|
|
import json
|
|
import requests
|
|
from typing import Any, Dict, List, Optional, Callable, Union
|
|
from dataclasses import dataclass
|
|
import platform
|
|
import shutil
|
|
import shlex
|
|
import ctypes
|
|
import tempfile
|
|
|
|
# Import actual working AgentDhal components
|
|
from .agentdhal_core import (
|
|
Agent,
|
|
AgentId,
|
|
MessageContext,
|
|
RoutedAgent,
|
|
message_handler,
|
|
default_subscription,
|
|
SingleThreadedAgentRuntime
|
|
)
|
|
|
|
try:
|
|
from .agentdhal_core.models import (
|
|
ChatCompletionClient,
|
|
LLMMessage,
|
|
SystemMessage,
|
|
UserMessage,
|
|
)
|
|
except ImportError:
|
|
# Define basic message types if not available
|
|
class LLMMessage:
|
|
def __init__(self, content: str):
|
|
self.content = content
|
|
|
|
class SystemMessage(LLMMessage):
|
|
def __init__(self, content: str):
|
|
super().__init__(content)
|
|
self.role = "system"
|
|
|
|
class UserMessage(LLMMessage):
|
|
def __init__(self, content: str):
|
|
super().__init__(content)
|
|
self.role = "user"
|
|
|
|
# Define ChatCompletionClient as a basic class
|
|
class ChatCompletionClient:
|
|
pass
|
|
|
|
try:
|
|
from .agentdhal_core.tools import FunctionTool, Tool
|
|
except ImportError:
|
|
# Create working tool implementation compatible with add_function(func, description)
|
|
class FunctionTool:
|
|
def __init__(self, func: Callable, description: str = ""):
|
|
self.func = func
|
|
self.description = description
|
|
Tool = FunctionTool # alias for typing
|
|
|
|
|
|
class HalModelClient:
|
|
"""Model client that integrates with DarkHal's LLM runtime."""
|
|
|
|
def __init__(self, model_name: str = "gpt-4"):
|
|
self.model_name = model_name
|
|
self.llm_model = None
|
|
self._initialize_model()
|
|
|
|
def _initialize_model(self):
|
|
"""Initialize the LLM model using DarkHal's runtime."""
|
|
try:
|
|
# Import from the main application's LLM runtime
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
|
from llm_runtime import load_model
|
|
|
|
# Load model using existing runtime
|
|
self.llm_model = load_model(
|
|
source=self.model_name,
|
|
device="auto",
|
|
quantization="4bit"
|
|
)
|
|
print(f"[Hal] Initialized model: {self.model_name}")
|
|
|
|
except Exception as e:
|
|
print(f"[Hal] Could not initialize model {self.model_name}: {e}")
|
|
self.llm_model = None
|
|
|
|
async def create_chat_completion(self, messages: List[LLMMessage], **kwargs):
|
|
"""Create chat completion using the loaded model (supports streaming)."""
|
|
try:
|
|
if not self.llm_model:
|
|
raise Exception("No model loaded")
|
|
|
|
# Convert messages to text format
|
|
conversation = ""
|
|
for msg in messages:
|
|
if hasattr(msg, 'role') and hasattr(msg, 'content'):
|
|
if msg.role == "system":
|
|
conversation += f"System: {msg.content}\n\n"
|
|
elif msg.role == "user":
|
|
conversation += f"User: {msg.content}\n\n"
|
|
elif msg.role == "assistant":
|
|
conversation += f"Assistant: {msg.content}\n\n"
|
|
else:
|
|
conversation += f"{msg.content}\n\n"
|
|
|
|
conversation += "Assistant: "
|
|
|
|
# Callbacks and options
|
|
stream = bool(kwargs.get('stream', False))
|
|
on_token = kwargs.get('on_token')
|
|
on_complete = kwargs.get('on_complete')
|
|
on_error = kwargs.get('on_error')
|
|
temperature = kwargs.get('temperature', 0.7)
|
|
max_tokens = kwargs.get('max_tokens', 2000)
|
|
|
|
# Try to build a config if available (optional)
|
|
cfg = None
|
|
try:
|
|
from llm_runtime import GenerateConfig # type: ignore
|
|
cfg = GenerateConfig(
|
|
max_tokens=max_tokens,
|
|
temperature=temperature
|
|
)
|
|
except Exception:
|
|
cfg = None
|
|
|
|
full_text = ""
|
|
|
|
# Streaming if supported
|
|
if stream and hasattr(self.llm_model, 'stream'):
|
|
try:
|
|
iterator = self.llm_model.stream(conversation, cfg) if cfg is not None else self.llm_model.stream(conversation)
|
|
for chunk in iterator:
|
|
if chunk:
|
|
full_text += str(chunk)
|
|
if on_token:
|
|
try:
|
|
on_token("request-1", str(chunk))
|
|
except Exception:
|
|
pass
|
|
if on_complete:
|
|
try:
|
|
on_complete("request-1", full_text, {"finish_reason": "stop"})
|
|
except Exception:
|
|
pass
|
|
except Exception as se:
|
|
if on_error:
|
|
try:
|
|
on_error("request-1", se)
|
|
except Exception:
|
|
pass
|
|
raise
|
|
|
|
else:
|
|
# Non-streaming path
|
|
if hasattr(self.llm_model, 'generate'):
|
|
full_text = self.llm_model.generate(conversation, cfg) if cfg is not None else self.llm_model.generate(conversation)
|
|
elif hasattr(self.llm_model, '__call__'):
|
|
full_text = self.llm_model(conversation)
|
|
else:
|
|
# Fallback for different model interfaces
|
|
full_text = str(self.llm_model)
|
|
if on_complete:
|
|
try:
|
|
on_complete("request-1", full_text, {"finish_reason": "stop"})
|
|
except Exception:
|
|
pass
|
|
|
|
# Create response object
|
|
class CompletionResponse:
|
|
def __init__(self, content):
|
|
self.content = content
|
|
self.function_calls = None
|
|
|
|
return CompletionResponse(full_text)
|
|
|
|
except Exception as e:
|
|
print(f"[Hal] Error generating response: {e}")
|
|
|
|
# Return error response
|
|
class CompletionResponse:
|
|
def __init__(self, content):
|
|
self.content = content
|
|
self.function_calls = None
|
|
|
|
if kwargs.get('on_error'):
|
|
try:
|
|
kwargs['on_error']("request-1", e)
|
|
except Exception:
|
|
pass
|
|
return CompletionResponse(f"I apologize, I encountered an error: {str(e)}")
|
|
|
|
def is_available(self) -> bool:
|
|
"""Check if the model client is available."""
|
|
return self.llm_model is not None
|
|
|
|
|
|
@dataclass
|
|
class DhalConfig:
|
|
"""Configuration for Hal agent."""
|
|
name: str = "Hal"
|
|
system_message: str = "You are Hal, an advanced AI assistant integrated into DarkHal 2.0. You help users with coding, analysis, security testing, and general AI tasks."
|
|
model: str = "gpt-4"
|
|
temperature: float = 0.7
|
|
max_tokens: int = 2000
|
|
tools: List[FunctionTool] = None # type: ignore
|
|
memory_limit: int = 10000
|
|
|
|
def __post_init__(self):
|
|
if self.tools is None:
|
|
self.tools = []
|
|
|
|
|
|
class Dhal(RoutedAgent):
|
|
"""
|
|
Dhal - The primary AI agent for DarkHal 2.0
|
|
|
|
Dhal provides advanced conversational AI capabilities with:
|
|
- Natural language understanding and generation
|
|
- Function calling and tool integration
|
|
- Code execution and analysis
|
|
- Memory and context management
|
|
- Team collaboration capabilities
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
config: DhalConfig,
|
|
model_client: ChatCompletionClient,
|
|
agent_id: Optional[AgentId] = None
|
|
):
|
|
"""Initialize Dhal agent."""
|
|
if agent_id is None:
|
|
agent_id = AgentId(config.name, "dhal")
|
|
|
|
super().__init__(config.name)
|
|
|
|
self.config = config
|
|
self.model_client = model_client
|
|
self.agent_id = agent_id
|
|
|
|
# Initialize conversation memory
|
|
self.conversation_history: List[LLMMessage] = []
|
|
if config.system_message:
|
|
self.conversation_history.append(SystemMessage(content=config.system_message))
|
|
|
|
# Tools and functions
|
|
self.tools = config.tools or []
|
|
self.function_map: Dict[str, Callable] = {}
|
|
|
|
# Agent state
|
|
self.is_active = False
|
|
self.current_task = None
|
|
|
|
self._register_default_tools()
|
|
|
|
def _register_default_tools(self):
|
|
"""Register default tools available to Hal."""
|
|
|
|
# Code execution tool
|
|
def execute_python(code: str) -> str:
|
|
"""Execute Python code and return the result."""
|
|
try:
|
|
import tempfile
|
|
import subprocess
|
|
|
|
# Create a temporary file to execute the code
|
|
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:
|
|
f.write(code)
|
|
temp_file = f.name
|
|
|
|
try:
|
|
# Execute the code and capture output
|
|
result = subprocess.run([sys.executable, temp_file],
|
|
capture_output=True, text=True, timeout=30, cwd=os.getcwd())
|
|
|
|
output = ""
|
|
if result.stdout:
|
|
output += f"Output:\n{result.stdout}"
|
|
if result.stderr:
|
|
output += f"\nErrors:\n{result.stderr}"
|
|
if result.returncode != 0:
|
|
output += f"\nReturn code: {result.returncode}"
|
|
|
|
return output or "Code executed successfully (no output)"
|
|
|
|
finally:
|
|
# Always clean up temp file
|
|
try:
|
|
os.unlink(temp_file)
|
|
except:
|
|
pass
|
|
|
|
except subprocess.TimeoutExpired:
|
|
return "Error: Code execution timed out (30 second limit)"
|
|
except Exception as e:
|
|
return f"Error executing code: {str(e)}"
|
|
|
|
# Web search tool
|
|
def web_search(query: str) -> str:
|
|
"""Search the web for information using DuckDuckGo API."""
|
|
try:
|
|
import requests
|
|
|
|
# Use DuckDuckGo instant answer API
|
|
url = "https://api.duckduckgo.com/"
|
|
params = {
|
|
'q': query,
|
|
'format': 'json',
|
|
'no_html': '1',
|
|
'skip_disambig': '1'
|
|
}
|
|
|
|
response = requests.get(url, params=params, timeout=10)
|
|
response.raise_for_status()
|
|
|
|
data = response.json()
|
|
|
|
result = f"Search query: {query}\n\n"
|
|
|
|
# Extract useful information
|
|
if data.get('AbstractText'):
|
|
result += f"Summary: {data['AbstractText']}\n\n"
|
|
|
|
if data.get('RelatedTopics'):
|
|
result += "Related information:\n"
|
|
for i, topic in enumerate(data['RelatedTopics'][:5]): # Limit to first 5
|
|
if isinstance(topic, dict) and 'Text' in topic:
|
|
result += f"{i + 1}. {topic['Text']}\n"
|
|
elif isinstance(topic, dict) and 'Topics' in topic:
|
|
# Handle nested topics
|
|
for subtopic in topic['Topics'][:2]:
|
|
if 'Text' in subtopic:
|
|
result += f"{i + 1}. {subtopic['Text']}\n"
|
|
|
|
if data.get('Answer'):
|
|
result += f"\nDirect answer: {data['Answer']}\n"
|
|
|
|
if data.get('Definition'):
|
|
result += f"\nDefinition: {data['Definition']}\n"
|
|
|
|
return result if result.strip() != f"Search query: {query}" else f"No specific results found for: {query}"
|
|
|
|
except Exception as e:
|
|
return f"Error performing web search: {str(e)}"
|
|
|
|
# Unrestricted file read
|
|
def read_file(filepath: str) -> str:
|
|
try:
|
|
abs_path = os.path.abspath(filepath)
|
|
with open(abs_path, 'r', encoding='utf-8') as f:
|
|
content = f.read()
|
|
return f"File: {abs_path}\nSize: {len(content)} characters\n\n{content}"
|
|
except UnicodeDecodeError:
|
|
# Fallback for binary files: return first 4096 bytes
|
|
try:
|
|
with open(abs_path, 'rb') as f:
|
|
data = f.read(4096)
|
|
return f"Binary file: {abs_path}\nFirst 4096 bytes:\n{data}"
|
|
except Exception as e:
|
|
return f"Error reading binary file: {abs_path}\n{e}"
|
|
except Exception as e:
|
|
return f"Error reading file: {abs_path}\n{e}"
|
|
|
|
# Unrestricted file write (creates parent dirs)
|
|
def write_file(filepath: str, content: str) -> str:
|
|
try:
|
|
abs_path = os.path.abspath(filepath)
|
|
os.makedirs(os.path.dirname(abs_path) or ".", exist_ok=True)
|
|
with open(abs_path, 'w', encoding='utf-8') as f:
|
|
f.write(content)
|
|
return f"Wrote {len(content)} bytes to {abs_path}"
|
|
except Exception as e:
|
|
return f"Error writing file: {abs_path}\n{e}"
|
|
|
|
# Unrestricted directory list
|
|
def list_files(directory: str = ".") -> str:
|
|
try:
|
|
abs_dir = os.path.abspath(directory)
|
|
items = []
|
|
for name in sorted(os.listdir(abs_dir)):
|
|
p = os.path.join(abs_dir, name)
|
|
if os.path.isdir(p):
|
|
items.append(f"[DIR] {name}/")
|
|
else:
|
|
items.append(f"[FILE] {name} ({os.path.getsize(p)} bytes)")
|
|
return f"Directory: {abs_dir}\n\n" + "\n".join(items)
|
|
except Exception as e:
|
|
return f"Error listing {directory}\n{e}"
|
|
|
|
# Unrestricted shell command (optionally with cwd/timeout)
|
|
def run_shell_command(command: str, cwd: str = None, timeout: int = 300) -> str:
|
|
try:
|
|
r = subprocess.run(
|
|
command,
|
|
shell=True,
|
|
cwd=cwd,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=timeout
|
|
)
|
|
out = r.stdout
|
|
if r.stderr:
|
|
out += f"\n[stderr]\n{r.stderr}"
|
|
out += f"\n[exit_code] {r.returncode}"
|
|
return out.strip()
|
|
except subprocess.TimeoutExpired:
|
|
return f"Error: timed out after {timeout}s"
|
|
except Exception as e:
|
|
return f"Error: {e}"
|
|
|
|
# --- NEW: PowerShell (Windows), Bash (Linux/macOS), and Ruby runner ---
|
|
def powershell(command: str, cwd: str = None, timeout: int = 120) -> str:
|
|
"""Run a PowerShell command on Windows (prefers pwsh, falls back to powershell)."""
|
|
exe = shutil.which("pwsh") or shutil.which("powershell")
|
|
if not exe:
|
|
return "Error: PowerShell not found"
|
|
cmd = [exe, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", command]
|
|
r = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout)
|
|
out = r.stdout
|
|
if r.stderr:
|
|
out += f"\n[stderr]\n{r.stderr}"
|
|
out += f"\n[exit_code] {r.returncode}"
|
|
return out.strip()
|
|
|
|
def bash(command: str, cwd: str = None, timeout: int = 120) -> str:
|
|
"""Run a Bash command on Linux/macOS."""
|
|
exe = shutil.which("bash")
|
|
if not exe:
|
|
return "Error: bash not found"
|
|
cmd = [exe, "-lc", command]
|
|
r = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout)
|
|
out = r.stdout
|
|
if r.stderr:
|
|
out += f"\n[stderr]\n{r.stderr}"
|
|
out += f"\n[exit_code] {r.returncode}"
|
|
return out.strip()
|
|
|
|
def ruby_run(script_path: str, args: str = "", cwd: str = None, timeout: int = 180,
|
|
use_bundler: bool = False) -> str:
|
|
"""Run a Ruby script. If use_bundler=True and Gemfile+bundle present → `bundle exec ruby`."""
|
|
ruby = shutil.which("ruby")
|
|
if not ruby:
|
|
return "Error: ruby not found in PATH"
|
|
argv = shlex.split(args) if args else []
|
|
if use_bundler and shutil.which("bundle") and os.path.exists(os.path.join(cwd or os.getcwd(), "Gemfile")):
|
|
cmd = ["bundle", "exec", ruby, script_path] + argv
|
|
else:
|
|
cmd = [ruby, script_path] + argv
|
|
r = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout)
|
|
out = r.stdout
|
|
if r.stderr:
|
|
out += f"\n[stderr]\n{r.stderr}"
|
|
out += f"\n[exit_code] {r.returncode}"
|
|
return out.strip()
|
|
|
|
# --- NEW: Elevated commands ---
|
|
def powershell_admin(command: str, cwd: str = None, timeout: int = 300) -> str:
|
|
"""
|
|
Run an elevated (UAC) PowerShell command on Windows.
|
|
Captures output by running a temporary elevated script and reading its output file.
|
|
"""
|
|
exe = shutil.which("pwsh") or shutil.which("powershell")
|
|
if not exe:
|
|
return "Error: PowerShell not found"
|
|
if platform.system() != "Windows":
|
|
return "Error: Admin PowerShell is Windows-only"
|
|
|
|
# Prepare temp script and output file
|
|
with tempfile.TemporaryDirectory() as td:
|
|
out_file = os.path.join(td, "out.txt")
|
|
script_file = os.path.join(td, "script.ps1")
|
|
# PS script: run the user's command and redirect ALL streams to file
|
|
script = f"$ErrorActionPreference='Continue'; & {{ {command} }} *> '{out_file}'"
|
|
with open(script_file, "w", encoding="utf-8") as f:
|
|
f.write(script)
|
|
|
|
# Build a PS command that elevates a new PowerShell instance to run the script
|
|
# We pass '-File "<script_file>"' to the elevated process and wait for it.
|
|
args_str = f"-NoProfile -NonInteractive -ExecutionPolicy Bypass -File \"{script_file}\""
|
|
ps_cmd = f"Start-Process -Verb RunAs -FilePath '{exe}' -ArgumentList '{args_str}' -Wait"
|
|
|
|
# Launch the elevation prompt
|
|
r = subprocess.run(
|
|
[exe, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", ps_cmd],
|
|
cwd=cwd, capture_output=True, text=True, timeout=timeout
|
|
)
|
|
|
|
# Read output written by elevated process
|
|
out = ""
|
|
try:
|
|
if os.path.exists(out_file):
|
|
with open(out_file, "r", encoding="utf-8", errors="replace") as f:
|
|
out = f.read().strip()
|
|
except Exception as e:
|
|
out += f"\n[read_error] {e}"
|
|
|
|
if r.stderr:
|
|
out += f"\n[launcher_stderr]\n{r.stderr.strip()}"
|
|
out += f"\n[launcher_exit_code] {r.returncode}"
|
|
return out.strip()
|
|
|
|
def sudo(command: str, cwd: str = None, timeout: int = 300) -> str:
|
|
"""
|
|
Run a command with elevated privileges on Linux (and most *nix).
|
|
Prefers pkexec (GUI polkit prompt); falls back to sudo (TTY or policykit may prompt).
|
|
"""
|
|
if platform.system() == "Windows":
|
|
return "Error: sudo is not available on Windows"
|
|
|
|
if shutil.which("pkexec"):
|
|
cmd = ["pkexec", "bash", "-lc", command]
|
|
elif shutil.which("sudo"):
|
|
# This will require a TTY or desktop prompt depending on environment
|
|
cmd = ["sudo", "bash", "-lc", command]
|
|
else:
|
|
return "Error: Neither pkexec nor sudo found"
|
|
|
|
r = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout)
|
|
out = r.stdout
|
|
if r.stderr:
|
|
out += f"\n[stderr]\n{r.stderr}"
|
|
out += f"\n[exit_code] {r.returncode}"
|
|
return out.strip()
|
|
|
|
# --- Mouse control (Windows + Linux via xdotool) ---
|
|
def mouse_move(x: int, y: int) -> str:
|
|
"""Move mouse to absolute screen coordinates (x, y)."""
|
|
sysname = platform.system()
|
|
if sysname == "Windows":
|
|
ctypes.windll.user32.SetCursorPos(int(x), int(y))
|
|
return "ok"
|
|
elif sysname in ("Linux", "FreeBSD"):
|
|
if not shutil.which("xdotool"):
|
|
return "Error: xdotool not found (Wayland may not support it)"
|
|
r = subprocess.run(["xdotool", "mousemove", str(int(x)), str(int(y))],
|
|
capture_output=True, text=True)
|
|
return "ok" if r.returncode == 0 else (r.stderr or r.stdout)
|
|
else:
|
|
return "Unsupported platform"
|
|
|
|
def mouse_click(button: str = "left") -> str:
|
|
"""Click mouse button: left|middle|right."""
|
|
sysname = platform.system()
|
|
if sysname == "Windows":
|
|
btn = button.lower()
|
|
if btn == "left":
|
|
down, up = 0x0002, 0x0004
|
|
elif btn == "right":
|
|
down, up = 0x0008, 0x0010
|
|
elif btn == "middle":
|
|
down, up = 0x0020, 0x0040
|
|
else:
|
|
return "Error: unknown button"
|
|
ctypes.windll.user32.mouse_event(down, 0, 0, 0, 0)
|
|
ctypes.windll.user32.mouse_event(up, 0, 0, 0, 0)
|
|
return "ok"
|
|
elif sysname in ("Linux", "FreeBSD"):
|
|
if not shutil.which("xdotool"):
|
|
return "Error: xdotool not found"
|
|
map_btn = {"left": "1", "middle": "2", "right": "3"}
|
|
code = map_btn.get(button.lower())
|
|
if not code:
|
|
return "Error: unknown button"
|
|
r = subprocess.run(["xdotool", "click", code], capture_output=True, text=True)
|
|
return "ok" if r.returncode == 0 else (r.stderr or r.stdout)
|
|
else:
|
|
return "Unsupported platform"
|
|
|
|
def mouse_scroll(lines: int = 1) -> str:
|
|
"""Scroll vertically: positive = up, negative = down."""
|
|
sysname = platform.system()
|
|
if sysname == "Windows":
|
|
# 120 units per wheel click
|
|
delta = int(lines) * 120
|
|
ctypes.windll.user32.mouse_event(0x0800, 0, 0, delta, 0)
|
|
return "ok"
|
|
elif sysname in ("Linux", "FreeBSD"):
|
|
if not shutil.which("xdotool"):
|
|
return "Error: xdotool not found"
|
|
clicks = abs(int(lines))
|
|
button = "4" if int(lines) > 0 else "5"
|
|
for _ in range(clicks):
|
|
subprocess.run(["xdotool", "click", button], capture_output=True)
|
|
return "ok"
|
|
else:
|
|
return "Unsupported platform"
|
|
|
|
# =========================
|
|
# WINDOWS XINPUT (DirectX)
|
|
# =========================
|
|
# Tools: xinput_list, xinput_get_state, xinput_set_vibration
|
|
def _load_xinput_dll():
|
|
if platform.system() != "Windows":
|
|
return None
|
|
for dll in ("XInput1_4.dll", "XInput1_3.dll", "XInput9_1_0.dll"):
|
|
try:
|
|
return ctypes.WinDLL(dll)
|
|
except OSError:
|
|
continue
|
|
return None
|
|
|
|
# Structures from XInput
|
|
class XINPUT_GAMEPAD(ctypes.Structure):
|
|
_fields_ = [
|
|
("wButtons", ctypes.c_ushort),
|
|
("bLeftTrigger", ctypes.c_ubyte),
|
|
("bRightTrigger", ctypes.c_ubyte),
|
|
("sThumbLX", ctypes.c_short),
|
|
("sThumbLY", ctypes.c_short),
|
|
("sThumbRX", ctypes.c_short),
|
|
("sThumbRY", ctypes.c_short),
|
|
]
|
|
|
|
class XINPUT_STATE(ctypes.Structure):
|
|
_fields_ = [
|
|
("dwPacketNumber", ctypes.c_uint),
|
|
("Gamepad", XINPUT_GAMEPAD),
|
|
]
|
|
|
|
class XINPUT_VIBRATION(ctypes.Structure):
|
|
_fields_ = [
|
|
("wLeftMotorSpeed", ctypes.c_ushort),
|
|
("wRightMotorSpeed", ctypes.c_ushort),
|
|
]
|
|
|
|
# Button bitmasks
|
|
XINPUT_BUTTONS = {
|
|
"DPAD_UP": 0x0001,
|
|
"DPAD_DOWN": 0x0002,
|
|
"DPAD_LEFT": 0x0004,
|
|
"DPAD_RIGHT": 0x0008,
|
|
"START": 0x0010,
|
|
"BACK": 0x0020,
|
|
"LEFT_THUMB": 0x0040,
|
|
"RIGHT_THUMB": 0x0080,
|
|
"LEFT_SHOULDER": 0x0100,
|
|
"RIGHT_SHOULDER": 0x0200,
|
|
"A": 0x1000,
|
|
"B": 0x2000,
|
|
"X": 0x4000,
|
|
"Y": 0x8000,
|
|
}
|
|
|
|
def xinput_list() -> str:
|
|
"""List connected XInput (Xbox-compatible) controllers on Windows."""
|
|
if platform.system() != "Windows":
|
|
return "Unsupported platform (XInput is Windows/DirectX)"
|
|
dll = _load_xinput_dll()
|
|
if not dll:
|
|
return "Error: XInput DLL not found"
|
|
|
|
XInputGetState = dll.XInputGetState
|
|
XInputGetState.argtypes = [ctypes.c_uint, ctypes.POINTER(XINPUT_STATE)]
|
|
XInputGetState.restype = ctypes.c_uint # 0 = success
|
|
|
|
found = []
|
|
for i in range(4):
|
|
state = XINPUT_STATE()
|
|
rc = XInputGetState(i, ctypes.byref(state))
|
|
found.append({"id": i, "connected": (rc == 0)})
|
|
|
|
return json.dumps(found)
|
|
|
|
def xinput_get_state(controller_id: int = 0) -> str:
|
|
"""Get state for a specific controller id (0-3). Returns JSON with buttons/axes/triggers."""
|
|
if platform.system() != "Windows":
|
|
return "Unsupported platform"
|
|
dll = _load_xinput_dll()
|
|
if not dll:
|
|
return "Error: XInput DLL not found"
|
|
|
|
XInputGetState = dll.XInputGetState
|
|
XInputGetState.argtypes = [ctypes.c_uint, ctypes.POINTER(XINPUT_STATE)]
|
|
XInputGetState.restype = ctypes.c_uint
|
|
|
|
state = XINPUT_STATE()
|
|
rc = XInputGetState(int(controller_id), ctypes.byref(state))
|
|
if rc != 0:
|
|
return f"Error: controller {controller_id} not connected"
|
|
|
|
gp = state.Gamepad
|
|
buttons = gp.wButtons
|
|
pressed = [name for name, mask in XINPUT_BUTTONS.items() if buttons & mask]
|
|
|
|
data = {
|
|
"id": int(controller_id),
|
|
"packet": state.dwPacketNumber,
|
|
"buttons": pressed,
|
|
"left_trigger": int(gp.bLeftTrigger),
|
|
"right_trigger": int(gp.bRightTrigger),
|
|
"thumb_lx": int(gp.sThumbLX),
|
|
"thumb_ly": int(gp.sThumbLY),
|
|
"thumb_rx": int(gp.sThumbRX),
|
|
"thumb_ry": int(gp.sThumbRY),
|
|
}
|
|
return json.dumps(data)
|
|
|
|
def xinput_set_vibration(controller_id: int = 0,
|
|
left_motor: int = 0,
|
|
right_motor: int = 0) -> str:
|
|
"""Set rumble (0-65535 per motor)."""
|
|
if platform.system() != "Windows":
|
|
return "Unsupported platform"
|
|
dll = _load_xinput_dll()
|
|
if not dll:
|
|
return "Error: XInput DLL not found"
|
|
|
|
XInputSetState = dll.XInputSetState
|
|
XInputSetState.argtypes = [ctypes.c_uint, ctypes.POINTER(XINPUT_VIBRATION)]
|
|
XInputSetState.restype = ctypes.c_uint
|
|
|
|
vib = XINPUT_VIBRATION(
|
|
wLeftMotorSpeed=max(0, min(65535, int(left_motor))),
|
|
wRightMotorSpeed=max(0, min(65535, int(right_motor)))
|
|
)
|
|
rc = XInputSetState(int(controller_id), ctypes.byref(vib))
|
|
return "ok" if rc == 0 else f"Error: rc={rc}"
|
|
|
|
# Register tools
|
|
self.add_function("execute_python", execute_python, "Execute Python code and return results")
|
|
self.add_function("web_search", web_search, "Search the web for information using DuckDuckGo")
|
|
self.add_function("read_file", read_file, "Read contents of a file")
|
|
self.add_function("write_file", write_file, "Write content to a file")
|
|
self.add_function("list_files", list_files, "List files and directories")
|
|
self.add_function("run_shell_command", run_shell_command, "Run shell commands")
|
|
# NEW registrations (standard shells and Ruby)
|
|
self.add_function("powershell", powershell, "Run a PowerShell command on Windows")
|
|
self.add_function("bash", bash, "Run a Bash command on Linux/macOS")
|
|
self.add_function("ruby_run", ruby_run, "Run a Ruby script (optionally via bundler)")
|
|
# NEW registrations (elevated)
|
|
self.add_function("powershell_admin", powershell_admin, "Run elevated PowerShell with UAC; captures output")
|
|
self.add_function("sudo", sudo, "Run a command with sudo or pkexec; user must approve")
|
|
# NEW registrations (input control)
|
|
self.add_function("mouse_move", mouse_move, "Move mouse to absolute screen coordinates")
|
|
self.add_function("mouse_click", mouse_click, "Click mouse button: left|middle|right")
|
|
self.add_function("mouse_scroll", mouse_scroll, "Scroll vertically by N lines (±)")
|
|
# Windows XInput (DirectX) gamepad tools
|
|
self.add_function("xinput_list", xinput_list, "List XInput controllers (Windows/DirectX)")
|
|
self.add_function("xinput_get_state", xinput_get_state, "Get XInput state for controller id (0-3)")
|
|
self.add_function("xinput_set_vibration", xinput_set_vibration, "Set XInput vibration (rumble)")
|
|
|
|
def add_function(self, name: str, func: Callable, description: str):
|
|
"""Add a function tool to Hal's capabilities."""
|
|
self.function_map[name] = func
|
|
tool = FunctionTool(func, description=description)
|
|
self.tools.append(tool)
|
|
|
|
@message_handler
|
|
async def handle_user_message(self, message: str, ctx: MessageContext) -> str:
|
|
"""Handle user messages and generate responses."""
|
|
try:
|
|
# Add user message to conversation history
|
|
user_msg = UserMessage(content=message)
|
|
self.conversation_history.append(user_msg)
|
|
|
|
# Prepare messages for model
|
|
messages = self._prepare_messages()
|
|
|
|
# Generate response using model client
|
|
response = await self.model_client.create_chat_completion(
|
|
messages=messages,
|
|
model=self.config.model,
|
|
temperature=self.config.temperature,
|
|
max_tokens=self.config.max_tokens,
|
|
tools=self.tools if self.tools else None
|
|
)
|
|
# Process response
|
|
assistant_message = response.content
|
|
# Handle function calls if present
|
|
if hasattr(response, 'function_calls') and response.function_calls:
|
|
assistant_message = await self._handle_function_calls(response.function_calls)
|
|
# Add assistant response to history
|
|
self.conversation_history.append(UserMessage(content=assistant_message))
|
|
# Manage memory limits
|
|
self._manage_memory()
|
|
return assistant_message
|
|
except Exception as e:
|
|
error_msg = f"Hal encountered an error: {str(e)}"
|
|
self.conversation_history.append(UserMessage(content=error_msg))
|
|
return error_msg
|
|
|
|
def send_dhal_message(
|
|
self,
|
|
conversation_id: str,
|
|
message: str,
|
|
stream: bool = False,
|
|
on_token: Optional[Callable[[str, str], None]] = None,
|
|
on_complete: Optional[Callable[[str, str, Dict[str, Any]], None]] = None,
|
|
on_error: Optional[Callable[[str, Exception], None]] = None
|
|
) -> None:
|
|
"""
|
|
Public entry to send a message to the agent with optional streaming callbacks.
|
|
Runs in a background thread to avoid blocking the UI thread.
|
|
"""
|
|
# Add user message to history
|
|
self.conversation_history.append(UserMessage(content=message))
|
|
messages = self._prepare_messages()
|
|
|
|
def _worker():
|
|
import asyncio
|
|
|
|
async def _run():
|
|
try:
|
|
# Delegate to model client with callbacks
|
|
resp = await self.model_client.create_chat_completion(
|
|
messages=messages,
|
|
model=self.config.model,
|
|
temperature=self.config.temperature,
|
|
max_tokens=self.config.max_tokens,
|
|
tools=self.tools if self.tools else None,
|
|
stream=stream,
|
|
on_token=(lambda req_id, delta: on_token and on_token(conversation_id, delta)),
|
|
on_complete=(lambda req_id, full, meta: (
|
|
self.conversation_history.append(UserMessage(content=full)),
|
|
self._manage_memory(),
|
|
on_complete and on_complete(conversation_id, full, meta)
|
|
)),
|
|
on_error=(lambda req_id, err: on_error and on_error(conversation_id, err))
|
|
)
|
|
# If not streaming, we need to append and call on_complete here
|
|
if not stream and resp and hasattr(resp, "content"):
|
|
assistant_message = resp.content
|
|
self.conversation_history.append(UserMessage(content=assistant_message))
|
|
self._manage_memory()
|
|
if on_complete:
|
|
on_complete(conversation_id, assistant_message, {"finish_reason": "stop"})
|
|
except Exception as e:
|
|
if on_error:
|
|
on_error(conversation_id, e)
|
|
|
|
asyncio.run(_run())
|
|
|
|
import threading as _threading
|
|
t = _threading.Thread(target=_worker, daemon=True)
|
|
t.start()
|
|
|
|
async def _handle_function_calls(self, function_calls: List[Dict]) -> str:
|
|
"""Handle function calls from the model."""
|
|
results = []
|
|
|
|
for call in function_calls:
|
|
func_name = call.get('name')
|
|
func_args = call.get('arguments', {})
|
|
|
|
if func_name in self.function_map:
|
|
try:
|
|
if asyncio.iscoroutinefunction(self.function_map[func_name]):
|
|
result = await self.function_map[func_name](**func_args)
|
|
else:
|
|
result = self.function_map[func_name](**func_args)
|
|
|
|
results.append(f"[{func_name}] {result}")
|
|
except Exception as e:
|
|
results.append(f"[{func_name}] Error: {str(e)}")
|
|
else:
|
|
results.append(f"[{func_name}] Function not found")
|
|
|
|
return "\n".join(results)
|
|
|
|
def _prepare_messages(self) -> List[LLMMessage]:
|
|
"""Prepare messages for the model, respecting context limits."""
|
|
# For now, return all messages. In production, implement smart truncation
|
|
return self.conversation_history.copy()
|
|
|
|
def _manage_memory(self):
|
|
"""Manage conversation memory to stay within limits."""
|
|
if len(self.conversation_history) > self.config.memory_limit:
|
|
# Keep system message and recent messages
|
|
system_msgs = [msg for msg in self.conversation_history if isinstance(msg, SystemMessage)]
|
|
recent_msgs = self.conversation_history[-(self.config.memory_limit - len(system_msgs)):]
|
|
self.conversation_history = system_msgs + recent_msgs
|
|
|
|
def get_status(self) -> Dict[str, Any]:
|
|
"""Get current status of Hal agent."""
|
|
return {
|
|
"name": self.config.name,
|
|
"active": self.is_active,
|
|
"model": self.config.model,
|
|
"conversation_length": len(self.conversation_history),
|
|
"available_tools": len(self.tools),
|
|
"current_task": self.current_task
|
|
}
|
|
|
|
def reset_conversation(self):
|
|
"""Reset conversation history while keeping system message."""
|
|
system_msgs = [msg for msg in self.conversation_history if isinstance(msg, SystemMessage)]
|
|
self.conversation_history = system_msgs
|
|
|
|
def update_config(self, **kwargs):
|
|
"""Update Hal's configuration."""
|
|
for key, value in kwargs.items():
|
|
if hasattr(self.config, key):
|
|
setattr(self.config, key, value)
|
|
|
|
|
|
# Convenience function to create Hal agent
|
|
def create_dhal(
|
|
name: str = "Dhal",
|
|
system_message: str = None,
|
|
model: str = "gpt-4",
|
|
model_client: ChatCompletionClient = None,
|
|
**kwargs
|
|
) -> Dhal:
|
|
"""Create a Dhal agent with specified configuration."""
|
|
|
|
if system_message is None:
|
|
system_message = f"You are {name}, an advanced AI assistant integrated into DarkHal 2.0. You help users with coding, analysis, security testing, and general AI tasks."
|
|
|
|
config = DhalConfig(
|
|
name=name,
|
|
system_message=system_message,
|
|
model=model,
|
|
**kwargs
|
|
)
|
|
|
|
# Initialize model client if not provided
|
|
if model_client is None:
|
|
try:
|
|
model_client = HalModelClient(model_name=model)
|
|
except Exception as e:
|
|
print(f"[Dhal] Warning: Could not initialize model client: {e}")
|
|
model_client = None
|
|
|
|
return Dhal(config, model_client)
|