diff --git a/TODO.md b/TODO.md index 7b99617..68271b0 100644 --- a/TODO.md +++ b/TODO.md @@ -13,18 +13,20 @@ - [x] Network policies, thread safety, trigger fix, race condition fix - [x] Install/uninstall scripts, deployed on 2 machines - [x] Refactor: firecracker-vm.ts shared helpers, skill extraction +- [x] Large output handling — save >2K results to file, preview + read_file skill +- [x] Session persistence — SQLite + FTS5, conversation history survives restarts +- [x] !logs — tail agent history from workspace +- [x] Context compression — cached summaries, configurable threshold/keep +- [x] write_file skill — agents can create and modify workspace files +- [x] Structured system prompt — explicit tool descriptions, multi-agent awareness +- [x] Per-template config — temperature, num_predict, context_size, compress settings +- [x] Response quality — 500-char deque storage, 1024 default output tokens, 250-token summaries +- [x] update.sh script — one-command rootfs patching and snapshot rebuild ## Next up (Phase 5 — by priority) -### Quick wins -- [ ] Large output handling — save >2K results to file, preview + read_file -- [ ] Iteration budget — configurable max rounds per template, prevent runaway loops - ### Medium effort - [ ] Skill registry git repo — shared skills on Gitea, `fireclaw skills pull` -- [ ] Session persistence — SQLite + FTS5 in workspace -- [ ] Context compression — summarize old turns when context gets long -- [ ] !logs — tail agent history from workspace ### Bigger items - [ ] Skill learning — agents create new skills from experience @@ -34,7 +36,6 @@ ## Polish -- [ ] Agent-to-agent response quality — 7B models parrot, needs better prompting or larger models - [ ] Cost tracking per interaction - [ ] Execution recording / audit trail - [ ] Update regression tests for skill system + channel layout diff --git a/agent/agent.py b/agent/agent.py index c4a5c2a..6485b37 100644 --- a/agent/agent.py +++ b/agent/agent.py @@ -8,6 +8,7 @@ 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 @@ -34,8 +35,13 @@ 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"), @@ -149,22 +155,76 @@ class IRCClient: # ─── 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: - 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." + 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\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." + 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] - for msg in channel_msgs[-CONTEXT_SIZE:]: - if msg["nick"] == NICK: + 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']}"}) @@ -245,6 +305,7 @@ def handle_message(irc, source_nick, target, text): TOOLS if TOOLS_ENABLED else [], SKILL_SCRIPTS, dispatch_tool, OLLAMA_URL, MAX_TOOL_ROUNDS, + num_predict=NUM_PREDICT, temperature=TEMPERATURE, ) if not response: @@ -256,8 +317,8 @@ def handle_message(irc, source_nick, target, text): 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}) - save_message(db_conn, NICK, channel, response[:200], full_text=response) + 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: diff --git a/agent/tools.py b/agent/tools.py index f7ebe02..909a62b 100644 --- a/agent/tools.py +++ b/agent/tools.py @@ -72,13 +72,13 @@ def ollama_request(ollama_url, payload): return json.loads(resp.read(2_000_000)) -def query_ollama(messages, runtime, tools, skill_scripts, dispatch_fn, ollama_url, max_rounds): +def query_ollama(messages, runtime, tools, skill_scripts, dispatch_fn, ollama_url, max_rounds, num_predict=1024, temperature=0.7): """Call Ollama chat API with skill-based tool support.""" payload = { "model": runtime["model"], "messages": messages, "stream": False, - "options": {"num_predict": 512}, + "options": {"num_predict": num_predict, "temperature": temperature}, } if tools: diff --git a/skills/write_file/SKILL.md b/skills/write_file/SKILL.md new file mode 100644 index 0000000..5d8bc6c --- /dev/null +++ b/skills/write_file/SKILL.md @@ -0,0 +1,17 @@ +--- +name: write_file +description: Write content to a file in /workspace. Creates parent directories if needed. Use this to save scripts, reports, data, or any output you want to persist. +parameters: + path: + type: string + description: Path to write (must be under /workspace) + required: true + content: + type: string + description: Content to write to the file + required: true + append: + type: boolean + description: If true, append to existing file instead of overwriting (default false) + required: false +--- diff --git a/skills/write_file/run.py b/skills/write_file/run.py new file mode 100644 index 0000000..252f8a7 --- /dev/null +++ b/skills/write_file/run.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +"""Write content to a file under /workspace.""" +import json +import os +import sys + +args = json.loads(sys.stdin.read()) +path = args.get("path", "") +content = args.get("content", "") +append = args.get("append", False) + +WORKSPACE = os.environ.get("WORKSPACE", "/workspace") + +resolved = os.path.realpath(path) +if not resolved.startswith(WORKSPACE + "/") and resolved != WORKSPACE: + print(f"[error: path must be under {WORKSPACE}]") + sys.exit(0) + +if os.path.isdir(resolved): + print(f"[error: {path} is a directory]") + sys.exit(0) + +try: + os.makedirs(os.path.dirname(resolved), exist_ok=True) + mode = "a" if append else "w" + with open(resolved, mode) as f: + f.write(content) + size = os.path.getsize(resolved) + action = "appended to" if append else "wrote" + print(f"[{action} {path} ({size} bytes)]") +except Exception as e: + print(f"[error: {e}]") diff --git a/src/agent-manager.ts b/src/agent-manager.ts index e39bc17..4cde05e 100644 --- a/src/agent-manager.ts +++ b/src/agent-manager.ts @@ -52,6 +52,16 @@ interface AgentTemplate { trigger: string; persona: string; network?: NetworkPolicy; + // Agent runtime settings (passed to config.json) + tools?: boolean; + context_size?: number; + num_predict?: number; + temperature?: number; + compress?: boolean; + compress_threshold?: number; + compress_keep?: number; + max_tool_rounds?: number; + max_response_lines?: number; } const AGENTS_FILE = join(CONFIG.baseDir, "agents.json"); @@ -95,7 +105,7 @@ export function listTemplates(): string[] { function injectAgentConfig( rootfsPath: string, - config: { nick: string; model: string; trigger: string }, + config: Record, persona: string ) { const mountPoint = `/tmp/fireclaw-agent-${Date.now()}`; @@ -112,12 +122,10 @@ function injectAgentConfig( // Write config (via stdin to avoid shell injection) const configJson = JSON.stringify({ - nick: config.nick, - model: config.model, - trigger: config.trigger, server: "172.16.0.1", port: 6667, ollama_url: "http://172.16.0.1:11434", + ...config, }); const configPath = join(mountPoint, "etc/agent/config.json"); execFileSync("sudo", ["tee", configPath], { @@ -234,13 +242,24 @@ export async function startAgent( // Clean stale socket from previous run try { unlinkSync(socketPath); } catch {} - // Prepare rootfs + // Prepare rootfs — pass all template settings to agent config copyFileSync(AGENT_ROOTFS, rootfsPath); - injectAgentConfig( - rootfsPath, - { nick, model, trigger: template.trigger }, - template.persona - ); + const agentConfig: Record = { + nick, + model, + trigger: template.trigger, + }; + // Forward optional template settings + for (const key of [ + "tools", "context_size", "num_predict", "temperature", + "compress", "compress_threshold", "compress_keep", + "max_tool_rounds", "max_response_lines", + ] as const) { + if ((template as unknown as Record)[key] !== undefined) { + agentConfig[key] = (template as unknown as Record)[key]; + } + } + injectAgentConfig(rootfsPath, agentConfig, template.persona); // Create/get persistent workspace const workspacePath = ensureWorkspace(name);