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:
17
TODO.md
17
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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
17
skills/write_file/SKILL.md
Normal file
17
skills/write_file/SKILL.md
Normal 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
32
skills/write_file/run.py
Normal 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}]")
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user