feat(runtime): v0.1.0 — adopt Voyager critic + Mindcraft modes/library/lint
Five concrete patterns from Voyager and Mindcraft, applied in our shape
without abandoning the git-as-evolution-substrate that makes pepa
distinct. Plus a first multi-agent surface so two bots from the same
repo can share intent.
1. runtime/critic.js (Voyager critic.txt)
- Spawns `pi -p` with a JSON-only critic prompt before a proposal is
written. {reasoning, success, critique}.
- success=true short-circuits the proposal (bot recovered between
detector tripping and now), saving Pi tokens on false positives.
- critique is spliced into the proposal body via attachCritique() so
the downstream auto-patcher has a sharp spec.
- Graceful: pi missing / timeout / unparseable JSON → proposal still
filed without the critic block.
2. scripts/lint-patch.js (Mindcraft coder._lintCode)
- Pre-flight gate between Pi commit and npm test: node --check, dynamic
import (catches missing named exports), regex extraction of
runSkill("id") calls cross-checked against the live registry.
- Cheaper than npm test, fails fast with a clear reason.
3. runtime/stuck-incident.renderActionTemplate (Voyager action_template.txt)
- All proposal bodies now follow the same fixed-section layout: Task /
Last result / Execution error / State / Metrics / Journal /
Scenarios / Critique / Fix / Edit scope / Forbidden.
4. runtime/skill-library.js (Mindcraft skill_library.getRelevantSkillDocs)
- Word-overlap ranking (Mindcraft's offline fallback) — zero deps,
deterministic. auto-patch.js injects top-3 similar skills into the
Pi prompt as "look at these patterns".
5. runtime/modes.js (Mindcraft modes.js)
- Declarative {name, interrupts, on, active, update(ctx)} chain that
runs BEFORE the curriculum each tick.
- Ships self_preservation (low HP → eat/flee), hunger (food<14 → eat),
night_shelter (night + bed in hand → sleep). Cleaner than ad-hoc
lastFleeAttempt cooldowns in reflex.js.
6. runtime/social/conversation.js + cmd:conv-say/conv-recent/conv-list
- File-JSONL topic channel so two bots from the same repo (different
usernames, different host dirs under state/) can append turns and
read peers. Skeleton — multi-agent collaboration on top later.
Differentiator preserved: every Pi-written skill still lands on main via
auto-patch.js (real git branch + smoke gate + cherry-pick). Voyager
keeps skills in a Chroma JSON, Mindcraft keeps them in RAM — pepa keeps
them as versioned source code reviewable in `git log`.
package.json: 0.0.1 → 0.1.0. 174/174 tests pass. README + AGENTS updated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
// Critic pass — adapted from Voyager's critic.txt.
|
||||
//
|
||||
// Before we file an auto-improvement proposal, ask Pi to look at the
|
||||
// state + recent attempts and answer: "did the bot actually fail, and if
|
||||
// so what should the patcher focus on?". Returns a JSON {reasoning,
|
||||
// success, critique}. The proposal body then embeds the critique so the
|
||||
// downstream auto-patch run has a sharp spec instead of raw metrics.
|
||||
//
|
||||
// Why a separate Pi call rather than baking it into the patch prompt:
|
||||
// * the patcher is biased toward writing code; the critic is biased
|
||||
// toward judging behaviour. Different prompt, different output.
|
||||
// * we cache the critique on the proposal, so the patcher can re-read
|
||||
// it without re-spending Pi tokens.
|
||||
// * if the critic says success=true, we DO NOT file the proposal at
|
||||
// all — the bot may have already recovered between when the stuck
|
||||
// detector tripped and now, and a false positive proposal just
|
||||
// burns Pi tokens.
|
||||
//
|
||||
// Failure modes are graceful: if Pi is missing, times out, or the JSON
|
||||
// can't be parsed, we return null and the caller files the proposal
|
||||
// without a critique section. Better to be slightly noisier than to
|
||||
// drop a real stuck incident.
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { info, warn } from "./log.js";
|
||||
|
||||
const PI_BIN = process.env.PI_BIN || "pi";
|
||||
const DEFAULT_TIMEOUT_MS = 60_000;
|
||||
|
||||
const SYSTEM_PROMPT = [
|
||||
"You are the critic for an autonomous Minecraft bot.",
|
||||
"",
|
||||
"You will receive a snapshot of the bot's state, the last skill result,",
|
||||
"recent scenario memory, and the milestone it is trying to reach. Decide",
|
||||
"whether the bot actually failed or merely paused, and if it failed,",
|
||||
"give a short, surgical critique a code-patching agent can act on.",
|
||||
"",
|
||||
"Respond with ONE JSON object — no prose, no markdown fence — matching:",
|
||||
'{ "reasoning": string, "success": boolean, "critique": string }',
|
||||
"",
|
||||
"Rules:",
|
||||
"- `success: true` ONLY if the bot's current state already satisfies the",
|
||||
" milestone. (e.g. milestone = chop 1 log AND inventory shows ≥1 log).",
|
||||
"- `critique` ≤ 300 chars, imperative voice, must name the specific code",
|
||||
" area or skill to change (e.g. \"gather.logs blacklists the target",
|
||||
" on the first silent_dig_failure — clear blacklist after movement\").",
|
||||
"- Do not invent file paths. If you don't know which file, name the skill",
|
||||
" id instead and let the patcher resolve.",
|
||||
"- No trailing commas, no single quotes — must parse with JSON.parse.",
|
||||
"",
|
||||
"Examples:",
|
||||
'INPUT: {"milestone":"chop 1 log","inventory":{"dirt":1},"lastResult":"gather.logs → no_target","scenarioTail":["gather.logs FAIL no_target ×5"]}',
|
||||
'OUTPUT: {"reasoning":"Bot has no logs and gather.logs returns no_target repeatedly while standing on dark_oak_leaves. findBlock callback matcher is broken under ViaBackwards.","success":false,"critique":"Switch gather.logs from bot.findBlock callback matcher to numeric-id matching via runtime/perception.js (see chopNearestTree)."}',
|
||||
"",
|
||||
'INPUT: {"milestone":"chop 1 log","inventory":{"oak_log":2},"lastResult":"gather.logs → done","scenarioTail":["gather.logs OK done"]}',
|
||||
'OUTPUT: {"reasoning":"Inventory already has 2 oak_log, exceeding the 1-log goal.","success":true,"critique":""}',
|
||||
].join("\n");
|
||||
|
||||
function buildUserBlock({ snapshot, lastResult, scenarioTail, milestone, kind }) {
|
||||
const slim = {
|
||||
kind,
|
||||
milestone: milestone ?? null,
|
||||
position: snapshot?.position ?? null,
|
||||
health: snapshot?.health ?? null,
|
||||
food: snapshot?.food ?? null,
|
||||
isDay: snapshot?.isDay ?? null,
|
||||
inventory: snapshot?.inventory ?? {},
|
||||
closestHostile: snapshot?.closestHostile ?? null,
|
||||
lastResult: lastResult
|
||||
? {
|
||||
label: lastResult.label,
|
||||
ok: !!lastResult.ok,
|
||||
code: lastResult.code ?? null,
|
||||
detail: typeof lastResult.detail === "string"
|
||||
? lastResult.detail.slice(0, 200)
|
||||
: lastResult.detail,
|
||||
}
|
||||
: null,
|
||||
scenarioTail: Array.isArray(scenarioTail)
|
||||
? scenarioTail.slice(-10).map((e) => `${e.skillId} ${e.ok ? "OK" : "FAIL"} ${e.code ?? ""}`)
|
||||
: [],
|
||||
};
|
||||
return `INPUT:\n${JSON.stringify(slim)}\nOUTPUT:`;
|
||||
}
|
||||
|
||||
// Strip a ```json fence or a leading "OUTPUT:" if Pi adds one anyway.
|
||||
function extractJsonObject(text) {
|
||||
if (!text) return null;
|
||||
let s = String(text).trim();
|
||||
s = s.replace(/^```(?:json)?\s*/i, "").replace(/```$/i, "").trim();
|
||||
s = s.replace(/^OUTPUT:\s*/i, "");
|
||||
// Find the first balanced {...}
|
||||
const first = s.indexOf("{");
|
||||
if (first < 0) return null;
|
||||
let depth = 0;
|
||||
for (let i = first; i < s.length; i++) {
|
||||
const c = s[i];
|
||||
if (c === "{") depth++;
|
||||
else if (c === "}") {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
const candidate = s.slice(first, i + 1);
|
||||
try { return JSON.parse(candidate); } catch { return null; }
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function requestCritique({ snapshot, lastResult, scenarioTail, milestone, kind, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
||||
const prompt = `${SYSTEM_PROMPT}\n\n${buildUserBlock({ snapshot, lastResult, scenarioTail, milestone, kind })}`;
|
||||
return new Promise((resolve) => {
|
||||
const startedAt = Date.now();
|
||||
let child;
|
||||
try {
|
||||
child = spawn(PI_BIN, ["-p", prompt], {
|
||||
env: { ...process.env, CI: "1" },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
} catch (e) {
|
||||
warn("critic", `spawn failed: ${e.message}`);
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stdout.on("data", (c) => { stdout += c; });
|
||||
child.stderr.on("data", (c) => { stderr += c; });
|
||||
const timer = setTimeout(() => {
|
||||
warn("critic", `pi timeout after ${timeoutMs}ms — killing`);
|
||||
try { child.kill("SIGTERM"); } catch {}
|
||||
}, timeoutMs);
|
||||
child.on("error", (e) => {
|
||||
clearTimeout(timer);
|
||||
warn("critic", `pi error: ${e.message}`);
|
||||
resolve(null);
|
||||
});
|
||||
child.on("exit", (code) => {
|
||||
clearTimeout(timer);
|
||||
const dur = Date.now() - startedAt;
|
||||
info("critic", `pi exited code=${code} after ${dur}ms (stdout=${stdout.length}B)`);
|
||||
if (code !== 0) {
|
||||
warn("critic", `pi non-zero: stderr=${stderr.slice(0, 200)}`);
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
const parsed = extractJsonObject(stdout);
|
||||
if (!parsed || typeof parsed.success !== "boolean") {
|
||||
warn("critic", `unparseable output: ${stdout.slice(0, 200)}`);
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
reasoning: String(parsed.reasoning ?? "").slice(0, 500),
|
||||
success: !!parsed.success,
|
||||
critique: String(parsed.critique ?? "").slice(0, 500),
|
||||
durationMs: dur,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Pure helper exported for tests.
|
||||
export const _internal = { extractJsonObject, buildUserBlock };
|
||||
Reference in New Issue
Block a user