diff --git a/package.json b/package.json index 12ab14c..bad80fd 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "tui:legacy": "tsx tui/tui.tsx", "propose:apply": "node scripts/propose-apply.js", "stop": "bash scripts/stop.sh", - "test": "node --test runtime/skills/contract.test.js runtime/skills/groups.test.js runtime/skills/compat.test.js runtime/skills/recovery-tunnel-out.test.js runtime/skills/pillar-up.test.js runtime/skills/dig-in.test.js runtime/skills/_common.test.js runtime/curriculum.test.js runtime/social/social.test.js runtime/social/conversation.test.js runtime/social/chat-history.test.js runtime/social/reply-pi.test.js runtime/stuck-incident.test.js runtime/compat.test.js runtime/reflex.test.js runtime/base-site.test.js runtime/locations.test.js runtime/watch-filter.test.js runtime/world-journal.test.js runtime/scenario-memory.test.js runtime/critic.test.js runtime/skill-library.test.js runtime/skill-registry.test.js runtime/modes.test.js runtime/pathfinder-watchdog.test.js runtime/services/inventory-ledger.test.js runtime/services/motion.test.js runtime/manifesto/needs.test.js runtime/manifesto/state.test.js runtime/goal/storyline.test.js runtime/goal/goal-manager.test.js runtime/goal/village-score.test.js runtime/awareness/events.test.js runtime/awareness/wedge-detector.test.js runtime/biome-affordances.test.js runtime/knowledge/knowledge.test.js runtime/llm/provider.test.js runtime/coach/postmortem.test.js runtime/coach/advice.test.js runtime/coach/reflect.test.js runtime/coach/fast-advisor.test.js runtime/coach/advisor-trigger.test.js runtime/coach/trigger-tuner.test.js runtime/persona/chatter.test.js scripts/edit-scope.test.js scripts/lint-patch.test.js" + "test": "node --test runtime/skills/contract.test.js runtime/skills/groups.test.js runtime/skills/compat.test.js runtime/skills/recovery-tunnel-out.test.js runtime/skills/pillar-up.test.js runtime/skills/dig-in.test.js runtime/skills/_common.test.js runtime/skills/flee.test.js runtime/skills/worlddelta.test.js runtime/curriculum.test.js runtime/social/social.test.js runtime/social/conversation.test.js runtime/social/chat-history.test.js runtime/social/reply-pi.test.js runtime/stuck-incident.test.js runtime/compat.test.js runtime/reflex.test.js runtime/base-site.test.js runtime/locations.test.js runtime/watch-filter.test.js runtime/world-journal.test.js runtime/scenario-memory.test.js runtime/critic.test.js runtime/skill-library.test.js runtime/skill-registry.test.js runtime/modes.test.js runtime/pathfinder-watchdog.test.js runtime/anti-loop.test.js runtime/services/inventory-ledger.test.js runtime/services/motion.test.js runtime/manifesto/needs.test.js runtime/manifesto/state.test.js runtime/goal/storyline.test.js runtime/goal/goal-manager.test.js runtime/goal/village-score.test.js runtime/goal/skill-graph.test.js runtime/awareness/events.test.js runtime/awareness/wedge-detector.test.js runtime/biome-affordances.test.js runtime/knowledge/knowledge.test.js runtime/llm/provider.test.js runtime/coach/postmortem.test.js runtime/coach/advice.test.js runtime/coach/reflect.test.js runtime/coach/fast-advisor.test.js runtime/coach/advisor-trigger.test.js runtime/coach/trigger-tuner.test.js runtime/persona/chatter.test.js scripts/edit-scope.test.js scripts/lint-patch.test.js" }, "dependencies": { "better-sqlite3": "^11.10.0", diff --git a/runtime/actions.js b/runtime/actions.js index 1b8aade..7ee8d1c 100644 --- a/runtime/actions.js +++ b/runtime/actions.js @@ -143,7 +143,30 @@ export async function attackNearest(bot, hostileType) { // ---- flee ------------------------------------------------------------------ -export async function fleeFrom(bot, fromEntity, distance = 16) { +async function blindRetreat(bot, dirYaw, blindMs = 7_000) { + const before = bot.entity.position.clone?.() ?? { ...bot.entity.position }; + try { bot.pathfinder?.stop?.(); } catch {} + try { await bot.look(dirYaw, 0, true); } catch {} + bot.setControlState("forward", true); + bot.setControlState("jump", true); + try { + await new Promise((r) => setTimeout(r, blindMs)); + } finally { + bot.setControlState("forward", false); + bot.setControlState("jump", false); + } + const after = bot.entity.position; + const moved = Math.hypot(after.x - before.x, after.z - before.z); + return { moved, after }; +} + +// v0.4.0 — when a MotionService is supplied (opts.motion), retreat via +// gotoSafe: a 12s wall-clock with a 4s progress watchdog returns a STRUCTURED +// {stuck|timeout|nopath} fast instead of hanging the full 30s that we observed +// live (zombie pinning the bot, every flee timing out, watchdog burning 3 +// replans). On any non-reached result we fall straight through to the blind +// retreat. Callers without a motion service keep the legacy 30s path. +export async function fleeFrom(bot, fromEntity, distance = 16, opts = {}) { ensurePathfinder(bot); const from = fromEntity?.position ?? bot.entity.position; const here = bot.entity.position; @@ -154,50 +177,62 @@ export async function fleeFrom(bot, fromEntity, distance = 16) { const tx = Math.round(here.x + (dx / len) * distance); const tz = Math.round(here.z + (dz / len) * distance); const ty = Math.round(here.y); + const dirYaw = -Math.atan2(dx / len, dz / len); + const blindMs = opts.blindMs ?? 7_000; info("action", `flee: from=${fromEntity?.name ?? "?"} → ${tx},${ty},${tz}`); // canDig:true here is deliberate — without it the bot gets permanently // stuck in dense tree canopy (observed live: bot perched at Y=85 inside // dark-oak leaves, every flee timed out for hours). We accept the risk of // chopping through scenery while panicking; it's how a player would react. - const movements = new Movements(bot); - movements.canDig = true; - movements.allow1by1towers = false; - bot.pathfinder.setMovements(movements); + try { + const movements = new Movements(bot); + movements.canDig = true; + movements.allow1by1towers = false; + bot.pathfinder.setMovements(movements); + } catch { + // fake/registry-less bot (tests) — skip movement tuning + } + + const goal = new goals.GoalNear(tx, ty, tz, 1); + + if (opts.motion?.gotoSafe) { + const res = await opts.motion.gotoSafe(goal, { + timeoutMs: opts.timeoutMs ?? 12_000, + stuckWindowMs: 4_000, + stuckDelta: 1.5, + label: `flee(${fromEntity?.name})`, + }); + if (res.ok) return { ok: true, detail: { to: { x: tx, y: ty, z: tz }, moved: res.movedBlocks } }; + warn("action", `flee gotoSafe → ${res.code} (moved ${res.movedBlocks}b); blind retreat`); + const b = await blindRetreat(bot, dirYaw, blindMs); + if (b.moved >= 4) { + return { ok: true, detail: { to: { x: Math.round(b.after.x), y: Math.round(b.after.y), z: Math.round(b.after.z) }, mode: "blind-retreat", moved: b.moved } }; + } + return { ok: false, code: res.code, detail: `flee ${res.code} then blind retreat moved ${b.moved.toFixed(2)}b` }; + } try { await withTimeout( - bot.pathfinder.goto(new goals.GoalNear(tx, ty, tz, 1)), + bot.pathfinder.goto(goal), 30_000, `fleeFrom(${fromEntity?.name})`, ); return { ok: true, detail: { to: { x: tx, y: ty, z: tz } } }; } catch (e) { warn("action", `flee path failed: ${e.message}; trying blind retreat`); - const before = bot.entity.position.clone?.() ?? { ...bot.entity.position }; - try { bot.pathfinder?.stop?.(); } catch {} - try { await bot.look(-Math.atan2(dx / len, dz / len), 0, true); } catch {} - bot.setControlState("forward", true); - bot.setControlState("jump", true); - try { - await new Promise((r) => setTimeout(r, 7_000)); - } finally { - bot.setControlState("forward", false); - bot.setControlState("jump", false); - } - const after = bot.entity.position; - const moved = Math.hypot(after.x - before.x, after.z - before.z); - if (moved >= 4) { + const b = await blindRetreat(bot, dirYaw, blindMs); + if (b.moved >= 4) { return { ok: true, detail: { - to: { x: Math.round(after.x), y: Math.round(after.y), z: Math.round(after.z) }, + to: { x: Math.round(b.after.x), y: Math.round(b.after.y), z: Math.round(b.after.z) }, mode: "blind-retreat", - moved, + moved: b.moved, }, }; } - warn("action", `flee blind retreat moved only ${moved.toFixed(2)} blocks`); + warn("action", `flee blind retreat moved only ${b.moved.toFixed(2)} blocks`); return { ok: false, detail: e.message }; } } diff --git a/runtime/anti-loop.js b/runtime/anti-loop.js new file mode 100644 index 0000000..f0cfe07 --- /dev/null +++ b/runtime/anti-loop.js @@ -0,0 +1,70 @@ +// Anti-loop detector (QW5). The scheduler already skips a (skill, situation) +// that has failed repeatedly (scenario-memory.shouldSkip), but that is keyed on +// a coarse situation hash and never escalates. This detector closes the loop +// the research describes: when the SAME skill fails N times inside a short +// window with no success in between, it (a) blacklists that skill for a cool-off +// and (b) emits a one-shot "fired" record the runtime turns into an +// improvement_request — so an operator/Codex gets a ticket instead of the bot +// silently thrashing (e.g. the live flee↔dig-in loop we observed). +// +// Pure + deterministic: inject `now` in tests. No disk, no bot. + +export function createAntiLoop({ + windowMs = 5 * 60_000, + threshold = 3, + blacklistMs = 30 * 60_000, + refireCooldownMs = 30 * 60_000, +} = {}) { + const state = new Map(); // key -> { fails: number[], blacklistUntil, lastFiredAt } + const firedQueue = []; + + function keyOf(skillId, targetKey) { + return targetKey ? `${skillId}@${targetKey}` : skillId; + } + function get(key) { + let s = state.get(key); + // lastFiredAt = -Infinity so the FIRST loop always fires (a real epoch + // `now` minus 0 would otherwise be < refireCooldownMs early in uptime). + if (!s) { s = { fails: [], blacklistUntil: 0, lastFiredAt: Number.NEGATIVE_INFINITY }; state.set(key, s); } + return s; + } + + function record({ skillId, ok, code = null, targetKey = null, detail = null, now = Date.now() }) { + if (!skillId) return { fired: false }; + const key = keyOf(skillId, targetKey); + const s = get(key); + if (ok) { s.fails = []; return { fired: false }; } + + s.fails.push(now); + s.fails = s.fails.filter((t) => now - t <= windowMs); + + if (s.fails.length >= threshold) { + s.blacklistUntil = now + blacklistMs; + const count = s.fails.length; + s.fails = []; // reset streak so we don't blacklist-spam every further fail + if (now - s.lastFiredAt >= refireCooldownMs) { + s.lastFiredAt = now; + const fired = { key, skillId, targetKey, count, code, detail, ts: now, until: s.blacklistUntil }; + firedQueue.push(fired); + return { fired: true, ...fired }; + } + } + return { fired: false }; + } + + function shouldSkip(skillId, targetKey = null, now = Date.now()) { + const s = state.get(keyOf(skillId, targetKey)); + return !!s && now < s.blacklistUntil; + } + + // Returns and clears the queue of newly-fired loops (for improvement_requests). + function drainFired() { + return firedQueue.splice(0); + } + + function snapshot() { + return { tracked: state.size, pendingFired: firedQueue.length }; + } + + return { record, shouldSkip, drainFired, snapshot, _state: () => state }; +} diff --git a/runtime/anti-loop.test.js b/runtime/anti-loop.test.js new file mode 100644 index 0000000..f4b93b0 --- /dev/null +++ b/runtime/anti-loop.test.js @@ -0,0 +1,58 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { createAntiLoop } from "./anti-loop.js"; + +test("fires after N failures in the window and blacklists the skill", () => { + const al = createAntiLoop({ windowMs: 60_000, threshold: 3, blacklistMs: 30_000 }); + assert.equal(al.record({ skillId: "survive.flee", ok: false, now: 1000 }).fired, false); + assert.equal(al.record({ skillId: "survive.flee", ok: false, now: 2000 }).fired, false); + const third = al.record({ skillId: "survive.flee", ok: false, now: 3000 }); + assert.equal(third.fired, true); + assert.equal(third.count, 3); + assert.equal(al.shouldSkip("survive.flee", null, 4000), true); + assert.equal(al.shouldSkip("survive.flee", null, 40_000), false); // blacklist expired +}); + +test("a success resets the fail streak", () => { + const al = createAntiLoop({ threshold: 3 }); + al.record({ skillId: "gather.logs", ok: false, now: 1 }); + al.record({ skillId: "gather.logs", ok: false, now: 2 }); + al.record({ skillId: "gather.logs", ok: true, now: 3 }); + const r = al.record({ skillId: "gather.logs", ok: false, now: 4 }); + assert.equal(r.fired, false); +}); + +test("failures outside the window do not accumulate", () => { + const al = createAntiLoop({ windowMs: 1000, threshold: 3 }); + al.record({ skillId: "s", ok: false, now: 0 }); + al.record({ skillId: "s", ok: false, now: 500 }); + const r = al.record({ skillId: "s", ok: false, now: 5000 }); // first two pruned + assert.equal(r.fired, false); +}); + +test("targetKey separates loops on different targets", () => { + const al = createAntiLoop({ threshold: 2 }); + al.record({ skillId: "mine", ok: false, targetKey: "A", now: 1 }); + const a2 = al.record({ skillId: "mine", ok: false, targetKey: "A", now: 2 }); + assert.equal(a2.fired, true); + const b1 = al.record({ skillId: "mine", ok: false, targetKey: "B", now: 3 }); + assert.equal(b1.fired, false); // different target, own streak +}); + +test("drainFired returns and clears the queue", () => { + const al = createAntiLoop({ threshold: 2 }); + al.record({ skillId: "x", ok: false, now: 1 }); + al.record({ skillId: "x", ok: false, now: 2 }); + assert.equal(al.drainFired().length, 1); + assert.equal(al.drainFired().length, 0); +}); + +test("refire cooldown prevents immediate re-fire", () => { + const al = createAntiLoop({ threshold: 2, blacklistMs: 1000, refireCooldownMs: 100_000 }); + al.record({ skillId: "x", ok: false, now: 1 }); + assert.equal(al.record({ skillId: "x", ok: false, now: 2 }).fired, true); + // after blacklist expires, two more fails — within refire cooldown → no fire + al.record({ skillId: "x", ok: false, now: 2000 }); + assert.equal(al.record({ skillId: "x", ok: false, now: 2100 }).fired, false); +}); diff --git a/runtime/bot.js b/runtime/bot.js index ad3e15b..d4b80e9 100644 --- a/runtime/bot.js +++ b/runtime/bot.js @@ -59,6 +59,7 @@ import { createScenarioMemory, situationHash } from "./scenario-memory.js"; import { createOwnedBlocksLedger } from "./owned-blocks.js"; import { createInventoryLedger } from "./services/inventory-ledger.js"; import { createMotionService } from "./services/motion.js"; +import { createAntiLoop } from "./anti-loop.js"; import { initKnowledge } from "./knowledge/index.js"; import { attach as attachCoach } from "./coach/postmortem.js"; import { attach as attachReflect } from "./coach/reflect.js"; @@ -109,6 +110,7 @@ const worldJournal = createWorldJournal(); const scenarioMemory = createScenarioMemory(); const ownedBlocks = createOwnedBlocksLedger(); const inventoryLedger = createInventoryLedger(); +const antiLoop = createAntiLoop(); const goalManager = createGoalManager(); let motionService = null; // armed on spawn (needs a live bot for pathfinder) let lastResult = null; // { label, ok, code, detail, ts } @@ -138,6 +140,7 @@ const reflexCtx = { metrics: skillMetrics, owned: ownedBlocks, ledger: inventoryLedger, + antiLoop, motion: null, // set on spawn alongside the pathfinder watchdog }; @@ -971,6 +974,40 @@ function tick() { void filePostCritique(wedged, "wedged"); } + // QW5 — anti-loop: a skill that failed ≥3× in 5 min is blacklisted by + // the detector; here we turn each fired loop into an improvement_request + // so the operator/Codex gets a concrete ticket instead of silent thrash. + for (const loop of antiLoop.drainFired()) { + try { + writeProposal({ + kind: `anti-loop-${loop.skillId}`, + summary: `${loop.skillId} looped ${loop.count}× in 5min (last code=${loop.code ?? "?"})`, + body: [ + `# Anti-loop: ${loop.skillId}`, + ``, + `The same skill failed ${loop.count} times within 5 minutes with no success`, + `in between, so it has been blacklisted until ${new Date(loop.until).toISOString()}.`, + ``, + `- skill: ${loop.skillId}`, + loop.targetKey ? `- target: ${loop.targetKey}` : `- target: (none)`, + `- last failure code: ${loop.code ?? "?"}`, + `- runtime state: ${lastSnapshot.runtimeState ?? "?"}`, + `- no-progress reason: ${lastSnapshot.noProgressReason ?? "?"}`, + `- position: ${JSON.stringify(lastSnapshot.position ?? null)}`, + `- milestone: ${lastSnapshot.contract?.milestone?.id ?? lastSnapshot.currentMilestone ?? "?"}`, + ``, + `## Suggested fix`, + `Either the skill's preconditions are too loose (it keeps being chosen`, + `when it cannot succeed here) or it needs a real recovery branch. Inspect`, + `runtime/skills/${loop.skillId.split(".").pop()}*.js and the scheduler path.`, + ].join("\n"), + editScope: ["runtime/skills/", "runtime/reflex.js", "runtime/modes.js"], + }); + } catch (e) { + warn("anti-loop", `writeProposal failed: ${e?.message ?? e}`); + } + } + ipc?.broadcast(EVENT_TYPES.STATUS, lastSnapshot); } else { lastSnapshot = { connected: false }; diff --git a/runtime/goal/goal-manager.js b/runtime/goal/goal-manager.js index 635fbd2..3a4440a 100644 --- a/runtime/goal/goal-manager.js +++ b/runtime/goal/goal-manager.js @@ -17,6 +17,7 @@ import { SETTLEMENT_CONTRACT } from "./contract.js"; import { checkInvariants, worldFromSnapshot } from "./invariants.js"; +import { prerequisitesMet } from "./skill-graph.js"; export function createGoalManager({ contract = SETTLEMENT_CONTRACT } = {}) { // Evaluate every milestone; returns the per-milestone invariant status plus @@ -57,6 +58,14 @@ export function createGoalManager({ contract = SETTLEMENT_CONTRACT } = {}) { suggestedSkill = null; } + // Annotate the suggestion with skill-graph prerequisite status (Plan4MC). + // Observability + a guard surface: if prereqs are unmet the curriculum + // chain should already be steering toward them, but we expose the gap. + if (suggestedSkill?.skillId) { + const pre = prerequisitesMet(suggestedSkill.skillId, world); + if (!pre.ok) suggestedSkill = { ...suggestedSkill, blockedBy: pre.missing }; + } + const reason = current.urgency > 0 && current.index > unmet[0].index ? `urgent:${current.id}(${current.urgency}) preempts ${unmet[0].id}` : `lowest unmet: ${current.id}`; diff --git a/runtime/goal/skill-graph.js b/runtime/goal/skill-graph.js new file mode 100644 index 0000000..b98df0f --- /dev/null +++ b/runtime/goal/skill-graph.js @@ -0,0 +1,89 @@ +// Skill dependency graph (Plan4MC-style, research §C). A static, declarative +// model of "what does this skill need, what does it produce". The contract +// already SEQUENCES the early game via the curriculum, so this graph is the +// queryable prerequisite layer on top: the GoalManager annotates each suggested +// skill with whether its prerequisites currently hold (surfaced for the TUI and +// as a guard against suggesting a skill that physically cannot succeed here). +// +// Requirement kinds: +// { item: , min } — need N of an item / group +// { tool: "pickaxe" | "axe" | "sword" } — need any tier of that tool +// Semantic groups: logs (*_log/_stem), planks (*_planks), sticks, cobblestone, +// wool (*_wool), coal, bed (*_bed). Anything else is matched as an exact name. + +import { totalMatching, has } from "./invariants.js"; + +const GROUP = { + logs: (k) => k.endsWith("_log") || k.endsWith("_stem"), + planks: (k) => k.endsWith("_planks"), + wool: (k) => k.endsWith("_wool"), + cobblestone: (k) => k === "cobblestone" || k === "cobbled_deepslate", + coal: (k) => k === "coal" || k === "charcoal", +}; + +const TOOL = { + pickaxe: (k) => k.endsWith("_pickaxe"), + axe: (k) => k.endsWith("_axe") && !k.endsWith("_pickaxe"), + sword: (k) => k.endsWith("_sword"), +}; + +export const SKILL_GRAPH = Object.freeze({ + "gather.logs": { requires: [], produces: ["logs"] }, + "gather.wool": { requires: [], produces: ["wool"] }, + "gather.stone": { requires: [{ tool: "pickaxe" }], produces: ["cobblestone"] }, + "craft.planks": { requires: [{ item: "logs", min: 1 }], produces: ["planks"] }, + "craft.sticks": { requires: [{ item: "planks", min: 2 }], produces: ["stick"] }, + "craft.wooden-axe": { requires: [{ item: "planks", min: 3 }, { item: "stick", min: 2 }], produces: ["wooden_axe"] }, + "craft.wooden-pickaxe": { requires: [{ item: "planks", min: 3 }, { item: "stick", min: 2 }], produces: ["wooden_pickaxe"] }, + "craft.wooden-sword": { requires: [{ item: "planks", min: 2 }, { item: "stick", min: 1 }], produces: ["wooden_sword"] }, + "craft.stone-axe": { requires: [{ item: "cobblestone", min: 3 }, { item: "stick", min: 2 }], produces: ["stone_axe"] }, + "craft.stone-pickaxe": { requires: [{ item: "cobblestone", min: 3 }, { item: "stick", min: 2 }], produces: ["stone_pickaxe"] }, + "craft.stone-sword": { requires: [{ item: "cobblestone", min: 2 }, { item: "stick", min: 1 }], produces: ["stone_sword"] }, + "craft.furnace": { requires: [{ item: "cobblestone", min: 8 }], produces: ["furnace"] }, + "craft.chest": { requires: [{ item: "planks", min: 8 }], produces: ["chest"] }, + "craft.torch": { requires: [{ item: "coal", min: 1 }, { item: "stick", min: 1 }], produces: ["torch"] }, + "craft.bed": { requires: [{ item: "wool", min: 3 }, { item: "planks", min: 3 }], produces: ["bed"] }, + "village.choose-base": { requires: [], produces: ["loc:base"] }, + "village.build-shelter": { requires: [{ item: "planks", min: 1 }], produces: ["loc:shelter"] }, + "village.place-chest": { requires: [{ item: "chest", min: 1 }], produces: ["loc:chest"] }, + "farm.wheat": { requires: [], produces: [] }, +}); + +function itemCount(inv, name) { + const g = GROUP[name]; + return g ? totalMatching(inv, g) : (inv?.[name] ?? 0); +} + +function hasTool(inv, kind) { + const t = TOOL[kind]; + if (!t) return false; + return Object.keys(inv ?? {}).some((k) => t(k) && (inv[k] ?? 0) > 0); +} + +// { ok, missing: [{ item|tool, min, have }] } for a skill given the world. +export function prerequisitesMet(skillId, world) { + const node = SKILL_GRAPH[skillId]; + if (!node) return { ok: true, missing: [], known: false }; + const inv = world?.inventory ?? {}; + const missing = []; + for (const req of node.requires) { + if (req.tool) { + if (!hasTool(inv, req.tool)) missing.push({ tool: req.tool }); + } else if (req.item) { + const have = itemCount(inv, req.item); + if (have < (req.min ?? 1)) missing.push({ item: req.item, min: req.min ?? 1, have }); + } + } + return { ok: missing.length === 0, missing, known: true }; +} + +export function canRun(skillId, world) { + return prerequisitesMet(skillId, world).ok; +} + +// All skills whose prerequisites currently hold (Plan4MC "frontier"). +export function runnableFrontier(world) { + return Object.keys(SKILL_GRAPH).filter((id) => canRun(id, world)); +} + +export const _internal = { GROUP, TOOL, itemCount, hasTool, has }; diff --git a/runtime/goal/skill-graph.test.js b/runtime/goal/skill-graph.test.js new file mode 100644 index 0000000..0359f49 --- /dev/null +++ b/runtime/goal/skill-graph.test.js @@ -0,0 +1,56 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { prerequisitesMet, canRun, runnableFrontier, _internal } from "./skill-graph.js"; + +function world(inv = {}) { + return { inventory: inv }; +} + +test("gather.logs needs nothing", () => { + assert.equal(canRun("gather.logs", world()), true); +}); + +test("gather.stone needs a pickaxe (any tier)", () => { + assert.equal(canRun("gather.stone", world({})), false); + assert.equal(canRun("gather.stone", world({ wooden_pickaxe: 1 })), true); + assert.equal(canRun("gather.stone", world({ stone_pickaxe: 1 })), true); +}); + +test("craft.planks needs a log (semantic group)", () => { + assert.equal(canRun("craft.planks", world({})), false); + assert.equal(canRun("craft.planks", world({ birch_log: 1 })), true); + assert.equal(canRun("craft.planks", world({ mangrove_stem: 2 })), true); +}); + +test("craft.furnace needs 8 cobblestone", () => { + assert.equal(canRun("craft.furnace", world({ cobblestone: 7 })), false); + assert.equal(canRun("craft.furnace", world({ cobblestone: 8 })), true); + assert.equal(canRun("craft.furnace", world({ cobbled_deepslate: 8 })), true); +}); + +test("prerequisitesMet reports the missing requirement detail", () => { + const r = prerequisitesMet("craft.wooden-pickaxe", world({ stick: 2 })); + assert.equal(r.ok, false); + assert.deepEqual(r.missing, [{ item: "planks", min: 3, have: 0 }]); +}); + +test("unknown skill is treated as runnable (known:false)", () => { + const r = prerequisitesMet("explore.far", world()); + assert.equal(r.ok, true); + assert.equal(r.known, false); +}); + +test("axe matcher excludes pickaxe", () => { + assert.equal(_internal.TOOL.axe("wooden_axe"), true); + assert.equal(_internal.TOOL.axe("wooden_pickaxe"), false); + assert.equal(_internal.TOOL.pickaxe("stone_pickaxe"), true); +}); + +test("runnableFrontier grows as inventory fills", () => { + const empty = runnableFrontier(world()); + const stocked = runnableFrontier(world({ oak_planks: 8, stick: 4, cobblestone: 8, wooden_pickaxe: 1 })); + assert.ok(stocked.length > empty.length); + assert.ok(stocked.includes("gather.stone")); + assert.ok(stocked.includes("craft.furnace")); +}); diff --git a/runtime/reflex.js b/runtime/reflex.js index fe42858..38fc3d2 100644 --- a/runtime/reflex.js +++ b/runtime/reflex.js @@ -712,6 +712,14 @@ function curriculumReflex(ctx) { } } + // QW5 anti-loop: this skill failed ≥3× in 5 min → it's blacklisted. Skip + // and nudge toward exploration so we leave the situation that loops it. + if (ctx.antiLoop?.shouldSkip(skillId)) { + ctx.skillBackoff = ctx.skillBackoff ?? {}; + ctx.skillBackoff["__wander_hint__"] = Date.now() + SKILL_BACKOFF_MS; + return { action: "noop", kind: "anti-loop-blacklisted", label: skillId }; + } + // v0.2.0 — consult learned lessons. If a high-confidence lesson says // "avoid in this situation", swap to its preferred // alternative (or back off entirely if no safe alternative is named). @@ -734,6 +742,9 @@ function curriculumReflex(ctx) { ctx.dispatch(() => runSkill(dispatchSkillId, ctx, dispatchArgs), dispatchSkillId, { onComplete: (res) => { ctx.skillBackoff = ctx.skillBackoff ?? {}; + // QW5 anti-loop bookkeeping: feed every outcome so repeated failures + // of the same skill get detected, blacklisted and ticketed. + ctx.antiLoop?.record({ skillId: dispatchSkillId, ok: !!res?.ok, code: res?.code ?? null }); if (advice.lessonId) reportAdviceOutcome({ lessonId: advice.lessonId, succeeded: !!res?.ok }); if (appliedRecommendationId) { markRecommendationOutcome(appliedRecommendationId, { diff --git a/runtime/skills/flee.js b/runtime/skills/flee.js index 90a33f3..6655af5 100644 --- a/runtime/skills/flee.js +++ b/runtime/skills/flee.js @@ -39,7 +39,7 @@ export const skill = Object.freeze({ async execute(ctx, args = {}) { const hit = nearestHostile(ctx.bot, args); if (!hit) return { ok: false, code: "no_hostile", detail: "no matching hostile after precondition", worldDelta: null }; - const res = await fleeFrom(ctx.bot, hit.entity, args.distance ?? 16); + const res = await fleeFrom(ctx.bot, hit.entity, args.distance ?? 16, { motion: ctx.motion, blindMs: args.blindMs }); if (res.ok) { return { ok: true, @@ -48,8 +48,10 @@ export const skill = Object.freeze({ worldDelta: { fledTo: res.detail?.to ?? null }, }; } + // Prefer the structured code from MotionService (stuck/timeout/nopath); + // fall back to string-sniffing the legacy path's message. const msg = String(res.detail ?? ""); - const code = msg.includes("timed out") ? "timeout" : "failed"; + const code = res.code ?? (msg.includes("timed out") ? "timeout" : "failed"); return { ok: false, code, detail: res.detail, worldDelta: null }; }, recover(ctx, result) { diff --git a/runtime/skills/flee.test.js b/runtime/skills/flee.test.js new file mode 100644 index 0000000..f5cfa80 --- /dev/null +++ b/runtime/skills/flee.test.js @@ -0,0 +1,62 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { skill } from "./flee.js"; + +function vec(x, y, z) { + return { + x, y, z, + clone() { return vec(x, y, z); }, + distanceTo(o) { return Math.hypot(x - o.x, y - o.y, z - o.z); }, + offset(dx, dy, dz) { return vec(x + dx, y + dy, z + dz); }, + }; +} + +function fakeBot({ moveOnForward = 0 } = {}) { + const bot = { + entity: { position: vec(0, 64, 0), yaw: 0 }, + entities: { z1: { name: "zombie", position: vec(2, 64, 0) } }, + loadPlugin() {}, + pathfinder: { goto: () => new Promise(() => {}), stop() {}, setMovements() {} }, + setControlState(name, on) { + if (name === "forward" && on && moveOnForward) { + bot.entity.position = vec(moveOnForward, 64, 0); + } + }, + async look() {}, + }; + return bot; +} + +test("flee returns done when motion reaches the retreat point", async () => { + const bot = fakeBot(); + const ctx = { bot, motion: { gotoSafe: async () => ({ ok: true, code: "reached", movedBlocks: 16 }) } }; + const res = await skill.execute(ctx, {}); + assert.equal(res.ok, true); + assert.equal(res.code, "done"); + assert.ok(res.worldDelta.fledTo); +}); + +test("flee falls back to blind retreat and succeeds when it moves far enough", async () => { + const bot = fakeBot({ moveOnForward: 8 }); + const ctx = { bot, motion: { gotoSafe: async () => ({ ok: false, code: "stuck", movedBlocks: 0 }) } }; + const res = await skill.execute(ctx, { blindMs: 20 }); + assert.equal(res.ok, true); + assert.equal(res.detail.mode, "blind-retreat"); +}); + +test("flee surfaces the structured motion code when stuck and blind retreat fails", async () => { + const bot = fakeBot({ moveOnForward: 0 }); // never moves + const ctx = { bot, motion: { gotoSafe: async () => ({ ok: false, code: "stuck", movedBlocks: 0 }) } }; + const res = await skill.execute(ctx, { blindMs: 20 }); + assert.equal(res.ok, false); + assert.equal(res.code, "stuck"); +}); + +test("flee precondition fails with no hostile", () => { + const bot = fakeBot(); + bot.entities = {}; + const pre = skill.preconditions({ bot }, {}); + assert.equal(pre.ok, false); + assert.equal(pre.code, "no_hostile"); +}); diff --git a/runtime/skills/index.js b/runtime/skills/index.js index 87296ca..ce28944 100644 --- a/runtime/skills/index.js +++ b/runtime/skills/index.js @@ -130,6 +130,18 @@ export const RUNNER_CODES = Object.freeze({ DONE: "done", }); +// Signed inventory diff between two count Maps (from InventoryLedger.mark/ +// snapshot). Used to attach the real world change to a skill result. +function invDiff(before, after) { + const out = {}; + const names = new Set([...(before?.keys?.() ?? []), ...(after?.keys?.() ?? [])]); + for (const n of names) { + const d = (after?.get?.(n) ?? 0) - (before?.get?.(n) ?? 0); + if (d !== 0) out[n] = d; + } + return out; +} + function normaliseResult(res, fallbackCode) { const ok = !!res?.ok; return { @@ -222,6 +234,12 @@ export async function runSkill(id, ctx, args = {}) { return result; } + // WorldDelta diff layer (research §TL;DR): snapshot the inventory before + // execute so we can attach the REAL inventory change to the result and, + // for skills that opt in via `expectGain`, assert the claimed gain actually + // happened instead of trusting the skill's own bookkeeping. + const ledgerBefore = ctx?.ledger?.mark?.() ?? null; + const timeoutMs = skill.timeoutMs ?? 30_000; let raw; try { @@ -277,6 +295,31 @@ export async function runSkill(id, ctx, args = {}) { return failed; } } + // Closed loop: compare the inventory now vs the pre-execute baseline. + if (result.ok && ledgerBefore && ctx?.ledger) { + try { if (ctx.bot) ctx.ledger.update(ctx.bot); } catch {} + const observed = invDiff(ledgerBefore, ctx.ledger.snapshot()); + if (Object.keys(observed).length > 0) { + result.worldDelta = { ...(result.worldDelta ?? {}), _invObserved: observed }; + } + // Opt-in strict check: the world must show the claimed gain. + if (skill.expectGain) { + const gain = ctx.ledger.gainedSince(ledgerBefore, skill.expectGain.matcher); + if (gain < (skill.expectGain.min ?? 1)) { + const failed = { + ok: false, + code: "world_unchanged", + detail: `${id} reported ok but ${skill.expectGain.label ?? "expected items"} did not increase (gain ${gain})`, + worldDelta: result.worldDelta, + }; + if (typeof skill.recover === "function") { + try { failed.recovery = skill.recover(ctx, failed) ?? null; } catch {} + } + return failed; + } + } + } + if (!result.ok && typeof skill.recover === "function") { try { result.recovery = skill.recover(ctx, result) ?? null; diff --git a/runtime/skills/worlddelta.test.js b/runtime/skills/worlddelta.test.js new file mode 100644 index 0000000..09ee675 --- /dev/null +++ b/runtime/skills/worlddelta.test.js @@ -0,0 +1,77 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { runSkill, _registerForTest } from "./index.js"; +import { createInventoryLedger } from "../services/inventory-ledger.js"; + +function botWithMutableInv(initial) { + let inv = initial; + return { + bot: { inventory: { items: () => inv } }, + set(next) { inv = next; }, + }; +} + +test("expectGain passes and attaches the observed inventory delta", async () => { + const m = botWithMutableInv([{ name: "cobblestone", count: 0 }]); + const ledger = createInventoryLedger(); + ledger.update(m.bot); + const teardown = _registerForTest({ + id: "test.mine-ok", + title: "t", timeoutMs: 1000, + preconditions: () => ({ ok: true }), + execute: async () => { m.set([{ name: "cobblestone", count: 5 }]); return { ok: true, code: "done", worldDelta: {} }; }, + expectGain: { matcher: "cobblestone", min: 1, label: "cobblestone" }, + }); + const res = await runSkill("test.mine-ok", { bot: m.bot, ledger }); + teardown(); + assert.equal(res.ok, true); + assert.equal(res.worldDelta._invObserved.cobblestone, 5); +}); + +test("expectGain fails with world_unchanged when the world did not move", async () => { + const m = botWithMutableInv([{ name: "cobblestone", count: 0 }]); + const ledger = createInventoryLedger(); + ledger.update(m.bot); + const teardown = _registerForTest({ + id: "test.mine-liar", + title: "t", timeoutMs: 1000, + preconditions: () => ({ ok: true }), + execute: async () => ({ ok: true, code: "done", worldDelta: {} }), // claims ok, gains nothing + expectGain: { matcher: "cobblestone", min: 1, label: "cobblestone" }, + }); + const res = await runSkill("test.mine-liar", { bot: m.bot, ledger }); + teardown(); + assert.equal(res.ok, false); + assert.equal(res.code, "world_unchanged"); +}); + +test("no ledger in ctx → no validation, skill passes untouched", async () => { + const teardown = _registerForTest({ + id: "test.no-ledger", + title: "t", timeoutMs: 1000, + preconditions: () => ({ ok: true }), + execute: async () => ({ ok: true, code: "done", worldDelta: { foo: 1 } }), + expectGain: { matcher: "diamond", min: 1 }, + }); + const res = await runSkill("test.no-ledger", { bot: { inventory: { items: () => [] } } }); + teardown(); + assert.equal(res.ok, true); + assert.equal(res.worldDelta.foo, 1); +}); + +test("observed delta is attached even without expectGain", async () => { + const m = botWithMutableInv([{ name: "oak_log", count: 2 }]); + const ledger = createInventoryLedger(); + ledger.update(m.bot); + const teardown = _registerForTest({ + id: "test.observe-only", + title: "t", timeoutMs: 1000, + preconditions: () => ({ ok: true }), + execute: async () => { m.set([{ name: "oak_log", count: 6 }]); return { ok: true, code: "done", worldDelta: null }; }, + }); + const res = await runSkill("test.observe-only", { bot: m.bot, ledger }); + teardown(); + assert.equal(res.ok, true); + assert.equal(res.worldDelta._invObserved.oak_log, 4); +});