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
This commit is contained in:
2026-04-08 18:28:26 +00:00
parent 6c4ad47b09
commit c827d341ab
6 changed files with 160 additions and 30 deletions

17
TODO.md
View File

@@ -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

View File

@@ -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:

View File

@@ -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:

View File

@@ -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
---

32
skills/write_file/run.py Normal file
View File

@@ -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}]")

View File

@@ -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<string, unknown>,
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<string, unknown> = {
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<string, unknown>)[key] !== undefined) {
agentConfig[key] = (template as unknown as Record<string, unknown>)[key];
}
}
injectAgentConfig(rootfsPath, agentConfig, template.persona);
// Create/get persistent workspace
const workspacePath = ensureWorkspace(name);