Files
fireclaw/agent/agent.py
ansible c827d341ab Overhaul agent quality — prompts, tools, config, compression
- Rewrite system prompt: structured sections, explicit tool descriptions
  with full SKILL.md descriptions, multi-agent awareness
- Add write_file skill for creating/modifying workspace files
- Per-template config passthrough: temperature, num_predict, context_size,
  compress settings, max_tool_rounds, max_response_lines
- Bump defaults: 1024 output tokens (was 512), 500-char deque (was 200),
  250-token summaries (was 150), compress threshold 16 (was 12), keep 8 (was 4)
- Cache compression by content hash — no redundant summarization
- Update all 5 templates with tuned settings per role
2026-04-08 18:28:26 +00:00

415 lines
15 KiB
Python

#!/usr/bin/env python3
"""Fireclaw IRC agent — connects to IRC, responds via Ollama with discoverable skills."""
import os
import socket
import json
import sys
import time
import signal
import threading
import urllib.request
from collections import deque
from skills import discover_skills, execute_skill, set_logger as set_skills_logger
from tools import load_memory, query_ollama, set_logger as set_tools_logger
from sessions import init_db, save_message, load_recent, set_logger as set_sessions_logger
# ─── 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)
NUM_PREDICT = CONFIG.get("num_predict", 1024)
TEMPERATURE = CONFIG.get("temperature", 0.7)
WORKSPACE = "/workspace"
SKILL_DIRS = ["/opt/skills", f"{WORKSPACE}/skills"]
COMPRESS_ENABLED = CONFIG.get("compress", True)
COMPRESS_THRESHOLD = CONFIG.get("compress_threshold", 16)
COMPRESS_KEEP = CONFIG.get("compress_keep", 8)
RUNTIME = {
"model": CONFIG.get("model", "qwen2.5-coder:7b"),
"trigger": CONFIG.get("trigger", "mention"),
"persona": PERSONA,
}
recent = deque(maxlen=CONTEXT_SIZE)
# ─── Logging ─────────────────────────────────────────────────────────
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
# Inject logger into submodules
set_skills_logger(log)
set_tools_logger(log)
set_sessions_logger(log)
# ─── Init ────────────────────────────────────────────────────────────
AGENT_MEMORY = load_memory(WORKSPACE)
TOOLS, SKILL_SCRIPTS = discover_skills(SKILL_DIRS)
log(f"Loaded {len(TOOLS)} skills: {', '.join(SKILL_SCRIPTS.keys())}")
db_conn = init_db(f"{WORKSPACE}/sessions.db")
for msg in load_recent(db_conn, CONTEXT_SIZE):
recent.append(msg)
log(f"Session: restored {len(recent)} messages")
def reload_memory():
global AGENT_MEMORY
AGENT_MEMORY = load_memory(WORKSPACE)
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]})")
result = execute_skill(script, fn_args, WORKSPACE, CONFIG)
if fn_name == "save_memory":
reload_memory()
return result
# ─── 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
# ─── Message Handling ────────────────────────────────────────────────
_compression_cache = {"hash": None, "summary": None}
def compress_messages(channel_msgs):
"""Summarize older messages, keep recent ones intact. Caches summary."""
if not COMPRESS_ENABLED or len(channel_msgs) <= COMPRESS_THRESHOLD:
return channel_msgs
older = channel_msgs[:-COMPRESS_KEEP]
keep = channel_msgs[-COMPRESS_KEEP:]
lines = [f"<{m['nick']}> {m['text']}" for m in older]
conversation = "\n".join(lines)
conv_hash = hash(conversation)
# Return cached summary if older messages haven't changed
if _compression_cache["hash"] == conv_hash and _compression_cache["summary"]:
return [{"nick": "_summary", "text": _compression_cache["summary"], "channel": channel_msgs[0]["channel"]}] + keep
try:
payload = json.dumps({
"model": RUNTIME["model"],
"messages": [
{"role": "system", "content": "Summarize this IRC conversation in 3-5 sentences. Preserve key facts, decisions, questions, and any specific data mentioned. Be thorough but concise."},
{"role": "user", "content": conversation},
],
"stream": False,
"options": {"num_predict": 250},
}).encode()
req = urllib.request.Request(f"{OLLAMA_URL}/api/chat", data=payload, headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=30) as resp:
data = json.loads(resp.read(500_000))
summary = data.get("message", {}).get("content", "").strip()
if summary:
_compression_cache["hash"] = conv_hash
_compression_cache["summary"] = summary
log(f"Compressed {len(older)} messages into summary")
return [{"nick": "_summary", "text": summary, "channel": channel_msgs[0]["channel"]}] + keep
except Exception as e:
log(f"Compression failed: {e}")
return channel_msgs
def build_messages(question, channel):
system = RUNTIME["persona"]
# Environment
system += f"\n\n## Environment\nYou are {NICK} in IRC channel {channel}. This is a multi-agent system — other nicks may be AI agents with their own tools. Keep responses concise (this is IRC). To address someone, prefix with their nick: 'coder: can you review this?'"
# Tools
if TOOLS_ENABLED and TOOLS:
system += "\n\n## Tools\nYou have tools — use them proactively instead of guessing or apologizing. If asked to do something, DO it with your tools."
for t in TOOLS:
fn = t["function"]
system += f"\n- **{fn['name']}**: {fn.get('description', '')}"
system += "\n\nYour workspace at /workspace persists across restarts. Write files, save results, read them back."
# Memory
if AGENT_MEMORY and AGENT_MEMORY != "# Agent Memory":
system += f"\n\n## Your Memory\n{AGENT_MEMORY}"
messages = [{"role": "system", "content": system}]
channel_msgs = [m for m in recent if m["channel"] == channel]
channel_msgs = compress_messages(channel_msgs[-CONTEXT_SIZE:])
for msg in channel_msgs:
if msg["nick"] == "_summary":
messages.append({"role": "system", "content": f"[earlier conversation summary] {msg['text']}"})
elif 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})
save_message(db_conn, source_nick, channel, text)
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, RUNTIME,
TOOLS if TOOLS_ENABLED else [],
SKILL_SCRIPTS, dispatch_tool,
OLLAMA_URL, MAX_TOOL_ROUNDS,
num_predict=NUM_PREDICT, temperature=TEMPERATURE,
)
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[:500], "channel": channel})
save_message(db_conn, NICK, channel, response[:500], full_text=response)
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()