Files
fireclaw/agent/agent.py

616 lines
20 KiB
Python

#!/usr/bin/env python3
"""Fireclaw IRC agent — connects to IRC, responds via Ollama with discoverable skills."""
import os
import re
import socket
import json
import sys
import time
import subprocess
import urllib.request
import urllib.error
import signal
import threading
from collections import deque
# Load config
with open("/etc/agent/config.json") as f:
CONFIG = json.load(f)
PERSONA = ""
try:
with open("/etc/agent/persona.md") as f:
PERSONA = f.read().strip()
except FileNotFoundError:
PERSONA = "You are a helpful assistant."
NICK = CONFIG.get("nick", "agent")
SERVER = CONFIG.get("server", "172.16.0.1")
PORT = CONFIG.get("port", 6667)
OLLAMA_URL = CONFIG.get("ollama_url", "http://172.16.0.1:11434")
CONTEXT_SIZE = CONFIG.get("context_size", 20)
MAX_RESPONSE_LINES = CONFIG.get("max_response_lines", 50)
TOOLS_ENABLED = CONFIG.get("tools", True)
MAX_TOOL_ROUNDS = CONFIG.get("max_tool_rounds", 10)
WORKSPACE = "/workspace"
SKILL_DIRS = ["/opt/skills", f"{WORKSPACE}/skills"]
RUNTIME = {
"model": CONFIG.get("model", "qwen2.5-coder:7b"),
"trigger": CONFIG.get("trigger", "mention"),
"persona": PERSONA,
}
recent = deque(maxlen=CONTEXT_SIZE)
# ─── Memory ──────────────────────────────────────────────────────────
AGENT_MEMORY = ""
try:
with open(f"{WORKSPACE}/MEMORY.md") as f:
AGENT_MEMORY = f.read().strip()
mem_dir = f"{WORKSPACE}/memory"
if os.path.isdir(mem_dir):
for fname in sorted(os.listdir(mem_dir)):
if fname.endswith(".md"):
try:
with open(f"{mem_dir}/{fname}") as f:
topic = fname.replace(".md", "")
AGENT_MEMORY += f"\n\n## {topic}\n{f.read().strip()}"
except Exception:
pass
except FileNotFoundError:
pass
# ─── Skill System ────────────────────────────────────────────────────
def parse_skill_md(path):
"""Parse a SKILL.md frontmatter into a tool definition."""
with open(path) as f:
content = f.read()
# Extract YAML frontmatter between ---
match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)
if not match:
return None
# Simple YAML-like parser (no pyyaml dependency)
fm = {}
current_key = None
current_param = None
params = {}
for line in match.group(1).split("\n"):
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
if line.startswith(" ") and current_key == "parameters":
# Inside parameters block
if line.startswith(" ") and current_param:
# Parameter property
k, _, v = stripped.partition(":")
v = v.strip().strip('"').strip("'")
if k.strip() == "required":
v = v.lower() == "true"
params[current_param][k.strip()] = v
elif ":" in stripped:
# New parameter
param_name = stripped.rstrip(":").strip()
current_param = param_name
params[param_name] = {}
elif ":" in line and not line.startswith(" "):
k, _, v = line.partition(":")
k = k.strip()
v = v.strip().strip('"').strip("'")
fm[k] = v
current_key = k
if k == "parameters":
current_param = None
if "name" not in fm:
return None
# Build Ollama tool definition
properties = {}
required = []
for pname, pdata in params.items():
properties[pname] = {
"type": pdata.get("type", "string"),
"description": pdata.get("description", ""),
}
if pdata.get("required", False):
required.append(pname)
return {
"type": "function",
"function": {
"name": fm["name"],
"description": fm.get("description", ""),
"parameters": {
"type": "object",
"properties": properties,
"required": required,
},
},
}
def discover_skills():
"""Scan skill directories and return tool definitions + script paths."""
tools = []
scripts = {}
for skill_dir in SKILL_DIRS:
if not os.path.isdir(skill_dir):
continue
for name in sorted(os.listdir(skill_dir)):
skill_path = os.path.join(skill_dir, name)
skill_md = os.path.join(skill_path, "SKILL.md")
if not os.path.isfile(skill_md):
continue
tool_def = parse_skill_md(skill_md)
if not tool_def:
continue
# Find the run script
for ext in ("run.py", "run.sh"):
script = os.path.join(skill_path, ext)
if os.path.isfile(script):
scripts[tool_def["function"]["name"]] = script
break
if tool_def["function"]["name"] in scripts:
tools.append(tool_def)
return tools, scripts
LARGE_OUTPUT_THRESHOLD = 2000
LARGE_OUTPUT_DIR = f"{WORKSPACE}/tool_outputs"
_output_counter = 0
def execute_skill(script_path, args):
"""Execute a skill script with args as JSON on stdin.
Large outputs are saved to a file with a preview returned."""
global _output_counter
env = os.environ.copy()
env["WORKSPACE"] = WORKSPACE
env["SEARX_URL"] = CONFIG.get("searx_url", "https://searx.mymx.me")
try:
result = subprocess.run(
["python3" if script_path.endswith(".py") else "bash", script_path],
input=json.dumps(args),
capture_output=True,
text=True,
timeout=120,
env=env,
)
output = result.stdout
if result.stderr:
output += f"\n[stderr] {result.stderr}"
output = output.strip() or "[no output]"
# Large output handling — save to file, return preview
if len(output) > LARGE_OUTPUT_THRESHOLD:
os.makedirs(LARGE_OUTPUT_DIR, exist_ok=True)
_output_counter += 1
filepath = f"{LARGE_OUTPUT_DIR}/output_{_output_counter}.txt"
with open(filepath, "w") as f:
f.write(output)
preview = output[:1500]
return f"{preview}\n\n[output truncated — full result ({len(output)} chars) saved to {filepath}. Use run_command to read it: cat {filepath}]"
return output
except subprocess.TimeoutExpired:
return "[skill timed out after 120s]"
except Exception as e:
return f"[skill error: {e}]"
# Discover skills at startup
TOOLS, SKILL_SCRIPTS = discover_skills()
LOG_FILE = f"{WORKSPACE}/agent.log" if os.path.isdir(WORKSPACE) else None
def log(msg):
line = f"[{time.strftime('%H:%M:%S')}] {msg}"
print(f"[agent:{NICK}] {line}", flush=True)
if LOG_FILE:
try:
with open(LOG_FILE, "a") as f:
f.write(line + "\n")
except Exception:
pass
log(f"Loaded {len(TOOLS)} skills: {', '.join(SKILL_SCRIPTS.keys())}")
# ─── IRC Client ──────────────────────────────────────────────────────
class IRCClient:
def __init__(self, server, port, nick):
self.server = server
self.port = port
self.nick = nick
self.sock = None
self.buf = ""
self._lock = threading.Lock()
def connect(self):
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.settimeout(300)
self.sock.connect((self.server, self.port))
self.send(f"NICK {self.nick}")
self.send(f"USER {self.nick} 0 * :Fireclaw Agent")
def send(self, msg):
with self._lock:
self.sock.sendall(f"{msg}\r\n".encode("utf-8"))
def join(self, channel):
self.send(f"JOIN {channel}")
def say(self, target, text):
for line in text.split("\n"):
line = line.strip()
if line:
while len(line) > 380:
self.send(f"PRIVMSG {target} :{line[:380]}")
line = line[380:]
self.send(f"PRIVMSG {target} :{line}")
def set_bot_mode(self):
self.send(f"MODE {self.nick} +B")
def recv_lines(self):
try:
data = self.sock.recv(4096)
except socket.timeout:
return []
if not data:
raise ConnectionError("Connection closed")
self.buf += data.decode("utf-8", errors="replace")
lines = self.buf.split("\r\n")
self.buf = lines.pop()
return lines
# ─── Tool Dispatch ───────────────────────────────────────────────────
def try_parse_tool_call(text):
"""Parse text-based tool calls (model dumps JSON as text)."""
text = re.sub(r"</?tool_call>", "", text).strip()
for start in range(len(text)):
if text[start] == "{":
for end in range(len(text), start, -1):
if text[end - 1] == "}":
try:
obj = json.loads(text[start:end])
name = obj.get("name")
args = obj.get("arguments", {})
if name and isinstance(args, dict):
return (name, args)
except json.JSONDecodeError:
continue
return None
def dispatch_tool(fn_name, fn_args, round_num):
"""Execute a tool call via the skill system."""
script = SKILL_SCRIPTS.get(fn_name)
if not script:
return f"[unknown tool: {fn_name}]"
log(f"Skill [{round_num}]: {fn_name}({str(fn_args)[:60]})")
# Handle save_memory reload
result = execute_skill(script, fn_args)
# Reload full memory if save_memory was called
if fn_name == "save_memory":
reload_memory()
return result
def reload_memory():
"""Reload all memory files from workspace."""
global AGENT_MEMORY
AGENT_MEMORY = ""
try:
with open(f"{WORKSPACE}/MEMORY.md") as f:
AGENT_MEMORY = f.read().strip()
mem_dir = f"{WORKSPACE}/memory"
if os.path.isdir(mem_dir):
for fname in sorted(os.listdir(mem_dir)):
if fname.endswith(".md"):
try:
with open(f"{mem_dir}/{fname}") as f:
topic = fname.replace(".md", "")
AGENT_MEMORY += f"\n\n## {topic}\n{f.read().strip()}"
except Exception:
pass
except FileNotFoundError:
pass
def ollama_request(payload):
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
f"{OLLAMA_URL}/api/chat",
data=data,
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=120) as resp:
return json.loads(resp.read())
def query_ollama(messages):
"""Call Ollama chat API with skill-based tool support."""
payload = {
"model": RUNTIME["model"],
"messages": messages,
"stream": False,
"options": {"num_predict": 512},
}
if TOOLS_ENABLED and TOOLS:
payload["tools"] = TOOLS
for round_num in range(MAX_TOOL_ROUNDS):
remaining = MAX_TOOL_ROUNDS - round_num
try:
data = ollama_request(payload)
except (urllib.error.URLError, TimeoutError) as e:
return f"[error: {e}]"
msg = data.get("message", {})
# Structured tool calls
tool_calls = msg.get("tool_calls")
if tool_calls:
messages.append(msg)
for tc in tool_calls:
fn = tc.get("function", {})
result = dispatch_tool(
fn.get("name", ""),
fn.get("arguments", {}),
round_num + 1,
)
# Warn when budget is running low
if remaining <= 2:
result += f"\n[warning: {remaining - 1} tool rounds remaining — wrap up]"
messages.append({"role": "tool", "content": result})
payload["messages"] = messages
continue
# Text-based tool calls
content = msg.get("content", "").strip()
parsed_tool = try_parse_tool_call(content)
if parsed_tool:
fn_name, fn_args = parsed_tool
if fn_name in SKILL_SCRIPTS:
messages.append({"role": "assistant", "content": content})
result = dispatch_tool(fn_name, fn_args, round_num + 1)
if remaining <= 2:
result += f"\n[warning: {remaining - 1} tool rounds remaining — wrap up]"
messages.append({
"role": "user",
"content": f"Tool result:\n{result}\n\nNow respond to the user based on this result.",
})
payload["messages"] = messages
continue
return content
return "[max tool rounds reached]"
# ─── Message Handling ────────────────────────────────────────────────
def build_messages(question, channel):
system = RUNTIME["persona"]
if TOOLS_ENABLED and TOOLS:
skill_names = [t["function"]["name"] for t in TOOLS]
system += "\n\nYou have access to tools: " + ", ".join(skill_names) + "."
system += "\nUse tools when needed rather than guessing. Your workspace at /workspace persists across restarts."
if AGENT_MEMORY and AGENT_MEMORY != "# Agent Memory":
system += f"\n\nIMPORTANT - Your persistent memory (facts you saved previously, use these to answer questions):\n{AGENT_MEMORY}"
system += f"\n\nYou are in IRC channel {channel}. Your nick is {NICK}. Keep responses concise — this is IRC."
system += "\nWhen you want to address another agent or user, always start your message with their nick followed by a colon, e.g. 'coder: can you review this?'. This is how IRC mentions work — without the prefix, they won't see your message."
messages = [{"role": "system", "content": system}]
channel_msgs = [m for m in recent if m["channel"] == channel]
for msg in channel_msgs[-CONTEXT_SIZE:]:
if msg["nick"] == NICK:
messages.append({"role": "assistant", "content": msg["text"]})
else:
messages.append({"role": "user", "content": f"<{msg['nick']}> {msg['text']}"})
last = messages[-1] if len(messages) > 1 else None
if not last or last.get("role") != "user" or question not in last.get("content", ""):
messages.append({"role": "user", "content": question})
return messages
def should_trigger(text):
if RUNTIME["trigger"] == "all":
return True
lower = text.lower()
nick = NICK.lower()
return (
lower.startswith(f"{nick}:") or
lower.startswith(f"{nick},") or
lower.startswith(f"{nick} ") or
lower.startswith(f"@{nick}") or
lower == nick or
text.startswith("!ask ")
)
def extract_question(text):
lower = text.lower()
for prefix in [
f"{NICK.lower()}: ",
f"{NICK.lower()}, ",
f"@{NICK.lower()} ",
f"{NICK.lower()} ",
]:
if lower.startswith(prefix):
return text[len(prefix):]
if text.startswith("!ask "):
return text[5:]
return text
_last_response_time = 0
_AGENT_COOLDOWN = 10
_cooldown_lock = threading.Lock()
def handle_message(irc, source_nick, target, text):
global _last_response_time
is_dm = not target.startswith("#")
channel = source_nick if is_dm else target
reply_to = source_nick if is_dm else target
recent.append({"nick": source_nick, "text": text, "channel": channel})
if source_nick == NICK:
return
if not is_dm and not should_trigger(text):
return
with _cooldown_lock:
now = time.time()
if now - _last_response_time < _AGENT_COOLDOWN:
log(f"Cooldown active, ignoring trigger from {source_nick}")
return
_last_response_time = now
question = extract_question(text) if not is_dm else text
log(f"Triggered by {source_nick} in {channel}: {question[:80]}")
def do_respond():
try:
messages = build_messages(question, channel)
response = query_ollama(messages)
if not response:
return
lines = response.split("\n")
if len(lines) > MAX_RESPONSE_LINES:
lines = lines[:MAX_RESPONSE_LINES]
lines.append(f"[truncated, {MAX_RESPONSE_LINES} lines max]")
irc.say(reply_to, "\n".join(lines))
recent.append({"nick": NICK, "text": response[:200], "channel": channel})
except Exception as e:
log(f"Error handling message: {e}")
try:
irc.say(reply_to, f"[error: {e}]")
except Exception:
pass
threading.Thread(target=do_respond, daemon=True).start()
# ─── Main Loop ───────────────────────────────────────────────────────
def run():
log(f"Starting agent: nick={NICK} model={RUNTIME['model']} tools={TOOLS_ENABLED}")
while True:
try:
irc = IRCClient(SERVER, PORT, NICK)
log(f"Connecting to {SERVER}:{PORT}...")
irc.connect()
def handle_sighup(signum, frame):
log("SIGHUP received, reloading config...")
try:
with open("/etc/agent/config.json") as f:
new_config = json.load(f)
RUNTIME["model"] = new_config.get("model", RUNTIME["model"])
RUNTIME["trigger"] = new_config.get("trigger", RUNTIME["trigger"])
try:
with open("/etc/agent/persona.md") as f:
RUNTIME["persona"] = f.read().strip()
except FileNotFoundError:
pass
log(f"Reloaded: model={RUNTIME['model']} trigger={RUNTIME['trigger']}")
irc.say("#agents", f"[reloaded: model={RUNTIME['model']}]")
except Exception as e:
log(f"Reload failed: {e}")
signal.signal(signal.SIGHUP, handle_sighup)
def handle_sigterm(signum, frame):
log("SIGTERM received, quitting IRC...")
try:
irc.send("QUIT :Agent shutting down")
except Exception:
pass
time.sleep(0.5)
sys.exit(0)
signal.signal(signal.SIGTERM, handle_sigterm)
registered = False
while True:
lines = irc.recv_lines()
for line in lines:
if line.startswith("PING"):
irc.send(f"PONG {line.split(' ', 1)[1]}")
continue
parts = line.split(" ")
if len(parts) < 2:
continue
if parts[1] == "001" and not registered:
registered = True
log("Registered with server")
irc.set_bot_mode()
irc.join("#agents")
log("Joined #agents")
if parts[1] == "INVITE" and len(parts) >= 3:
invited_channel = parts[-1].lstrip(":")
inviter = parts[0].split("!")[0].lstrip(":")
log(f"Invited to {invited_channel} by {inviter}, joining...")
irc.join(invited_channel)
if parts[1] == "PRIVMSG" and len(parts) >= 4:
source_nick = parts[0].split("!")[0].lstrip(":")
target = parts[2]
text = " ".join(parts[3:]).lstrip(":")
handle_message(irc, source_nick, target, text)
except (ConnectionError, OSError, socket.timeout) as e:
log(f"Disconnected: {e}. Reconnecting in 5s...")
time.sleep(5)
except KeyboardInterrupt:
log("Shutting down.")
sys.exit(0)
if __name__ == "__main__":
run()