feat(runtime): skill substrate + dynamic groups (Phase 2) #14
@@ -167,6 +167,49 @@ parsing the log stream:
|
||||
| `lastEscalation` | `{ ts, ageMs }` of the most recent Pi auto-escalation. |
|
||||
| `reflexPaused` | mirror of the local pause flag (so TUI shows the right state immediately). |
|
||||
|
||||
### Skill substrate (Phase 2)
|
||||
|
||||
Lives under `runtime/skills/`. A **skill** is a small composable unit of
|
||||
survival behaviour with a uniform contract:
|
||||
|
||||
```js
|
||||
export const skill = {
|
||||
id: "namespace.action",
|
||||
title: "Human label",
|
||||
timeoutMs: 45_000,
|
||||
preconditions(ctx) -> { ok, code?, detail? }
|
||||
async execute(ctx, args) -> { ok, code, detail, worldDelta }
|
||||
validate?(ctx, result) -> boolean // optional
|
||||
recover?(ctx, result) -> any | null // optional
|
||||
}
|
||||
```
|
||||
|
||||
Registered skills are dispatched via `runSkill(id, ctx, args)` from
|
||||
`runtime/skills/index.js`. The runner enforces the timeout, normalises
|
||||
the result shape, runs `validate()` and calls `recover()` on failure
|
||||
so the scheduler can act on the hint (e.g. "switch to wander"). Stable
|
||||
failure codes the runner itself emits live in `RUNNER_CODES`
|
||||
(`unknown_skill`, `precondition_failed`, `timeout`, `threw`,
|
||||
`validation_failed`, `done`).
|
||||
|
||||
Item/block groups are dynamic: `runtime/skills/groups.js` exposes
|
||||
`logs(bot)`, `planks(bot)`, `sticks(bot)`, `beds(bot)`, `foods(bot)`,
|
||||
`axes(bot)`, `pickaxes(bot)`, `swords(bot)` — every group is derived
|
||||
from `bot.registry`, so a version-sensitive item that doesn't exist on
|
||||
the connected server simply doesn't appear in the set and skills
|
||||
return `code: "unsupported_version"` instead of crashing.
|
||||
|
||||
Reference skills shipped today: `gather.logs`, `survive.eat`,
|
||||
`explore.wander`. The reflex loop still calls the older
|
||||
`runtime/actions.js` primitives directly — porting more behaviours to
|
||||
skills lands in later phases.
|
||||
|
||||
Run the contract + groups tests:
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
### Optional: prismarine-viewer
|
||||
|
||||
Set `VIEWER_PORT=<port>` in `.env` to launch
|
||||
|
||||
+2
-1
@@ -15,7 +15,8 @@
|
||||
"bot:bare": "node runtime/bot.js",
|
||||
"tui": "tsx tui/tui.tsx",
|
||||
"propose:apply": "node scripts/propose-apply.js",
|
||||
"stop": "bash scripts/stop.sh"
|
||||
"stop": "bash scripts/stop.sh",
|
||||
"test": "node --test runtime/skills/contract.test.js runtime/skills/groups.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"dotenv": "^16.4.5",
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// gather.logs — find the nearest log block matching the server's log
|
||||
// registry, path to it, mine it. Wraps the existing actions.js primitive
|
||||
// while exposing the survival-skill contract (preconditions, timeout,
|
||||
// structured worldDelta, recovery hint).
|
||||
|
||||
import { chopNearestTree } from "../actions.js";
|
||||
import { logs as logBlocks } from "./groups.js";
|
||||
|
||||
export const skill = Object.freeze({
|
||||
id: "gather.logs",
|
||||
title: "Gather logs",
|
||||
timeoutMs: 60_000,
|
||||
preconditions(ctx) {
|
||||
if (!ctx?.bot) return { ok: false, code: "no_bot", detail: "bot missing" };
|
||||
const known = logBlocks(ctx.bot);
|
||||
if (known.size === 0) {
|
||||
return { ok: false, code: "unsupported_version", detail: "no log blocks in registry" };
|
||||
}
|
||||
return { ok: true };
|
||||
},
|
||||
async execute(ctx) {
|
||||
const res = await chopNearestTree(ctx.bot);
|
||||
if (res.ok) {
|
||||
return {
|
||||
ok: true,
|
||||
code: "done",
|
||||
detail: res.detail,
|
||||
worldDelta: {
|
||||
choppedAt: res.detail?.at ?? null,
|
||||
logType: res.detail?.logType ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
const msg = String(res.detail ?? "");
|
||||
const code = msg.includes("no reachable log")
|
||||
? "no_target"
|
||||
: msg.includes("timed out")
|
||||
? "timeout"
|
||||
: "failed";
|
||||
return { ok: false, code, detail: res.detail, worldDelta: null };
|
||||
},
|
||||
validate(ctx, result) {
|
||||
return result.ok && !!result.worldDelta?.logType;
|
||||
},
|
||||
recover(ctx, result) {
|
||||
// Tell the caller: if there was no reachable log, switching to wander
|
||||
// for ~60 s is the right next move — same heuristic the autonomous
|
||||
// reflex already uses.
|
||||
if (result.code === "no_target") {
|
||||
return { hint: "wander", reason: "no log within 32 blocks" };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
// Contract tests for runtime/skills/index.js. Run with: node --test runtime/skills
|
||||
//
|
||||
// We avoid spinning up a real mineflayer bot — synthetic skills are
|
||||
// registered via _registerForTest and exercise every branch of runSkill:
|
||||
// preconditions, timeout, exception, validate, recover.
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { runSkill, RUNNER_CODES, _registerForTest } from "./index.js";
|
||||
|
||||
const ctx = {}; // skills under test ignore ctx fully
|
||||
|
||||
test("unknown skill returns unknown_skill code", async () => {
|
||||
const res = await runSkill("does.not.exist", ctx);
|
||||
assert.equal(res.ok, false);
|
||||
assert.equal(res.code, RUNNER_CODES.UNKNOWN_SKILL);
|
||||
});
|
||||
|
||||
test("preconditions gate execution", async () => {
|
||||
const teardown = _registerForTest({
|
||||
id: "test.precondition-block",
|
||||
title: "blocked",
|
||||
timeoutMs: 1000,
|
||||
preconditions: () => ({ ok: false, code: "missing_x", detail: "no x" }),
|
||||
execute: async () => {
|
||||
throw new Error("should not run");
|
||||
},
|
||||
});
|
||||
try {
|
||||
const res = await runSkill("test.precondition-block", ctx);
|
||||
assert.equal(res.ok, false);
|
||||
assert.equal(res.code, "missing_x");
|
||||
assert.equal(res.detail, "no x");
|
||||
} finally {
|
||||
teardown();
|
||||
}
|
||||
});
|
||||
|
||||
test("preconditions that throw produce precondition_failed", async () => {
|
||||
const teardown = _registerForTest({
|
||||
id: "test.precondition-throw",
|
||||
preconditions: () => {
|
||||
throw new Error("kaboom");
|
||||
},
|
||||
execute: async () => ({ ok: true }),
|
||||
});
|
||||
try {
|
||||
const res = await runSkill("test.precondition-throw", ctx);
|
||||
assert.equal(res.ok, false);
|
||||
assert.equal(res.code, RUNNER_CODES.PRECONDITION_FAILED);
|
||||
assert.match(res.detail, /kaboom/);
|
||||
} finally {
|
||||
teardown();
|
||||
}
|
||||
});
|
||||
|
||||
test("timeout fires and recover is called", async () => {
|
||||
let recovered = false;
|
||||
const teardown = _registerForTest({
|
||||
id: "test.timeout",
|
||||
timeoutMs: 50,
|
||||
preconditions: () => ({ ok: true }),
|
||||
execute: () =>
|
||||
new Promise((resolve) => {
|
||||
// never resolves within the timeout
|
||||
setTimeout(() => resolve({ ok: true }), 500);
|
||||
}),
|
||||
recover: () => {
|
||||
recovered = true;
|
||||
return { hint: "retry-later" };
|
||||
},
|
||||
});
|
||||
try {
|
||||
const res = await runSkill("test.timeout", ctx);
|
||||
assert.equal(res.ok, false);
|
||||
assert.equal(res.code, RUNNER_CODES.TIMEOUT);
|
||||
assert.equal(recovered, true);
|
||||
assert.deepEqual(res.recovery, { hint: "retry-later" });
|
||||
} finally {
|
||||
teardown();
|
||||
}
|
||||
});
|
||||
|
||||
test("execute throw -> threw code with recovery hint", async () => {
|
||||
const teardown = _registerForTest({
|
||||
id: "test.throws",
|
||||
preconditions: () => ({ ok: true }),
|
||||
execute: async () => {
|
||||
throw new Error("oops");
|
||||
},
|
||||
recover: (ctx, result) => ({ saw: result.code }),
|
||||
});
|
||||
try {
|
||||
const res = await runSkill("test.throws", ctx);
|
||||
assert.equal(res.ok, false);
|
||||
assert.equal(res.code, RUNNER_CODES.THREW);
|
||||
assert.match(res.detail, /oops/);
|
||||
assert.deepEqual(res.recovery, { saw: RUNNER_CODES.THREW });
|
||||
} finally {
|
||||
teardown();
|
||||
}
|
||||
});
|
||||
|
||||
test("happy path returns done with worldDelta", async () => {
|
||||
const teardown = _registerForTest({
|
||||
id: "test.happy",
|
||||
preconditions: () => ({ ok: true }),
|
||||
execute: async () => ({ ok: true, code: "done", detail: { count: 4 }, worldDelta: { gathered: 4 } }),
|
||||
});
|
||||
try {
|
||||
const res = await runSkill("test.happy", ctx);
|
||||
assert.equal(res.ok, true);
|
||||
assert.equal(res.code, "done");
|
||||
assert.deepEqual(res.worldDelta, { gathered: 4 });
|
||||
} finally {
|
||||
teardown();
|
||||
}
|
||||
});
|
||||
|
||||
test("validate failure flips ok to false with validation_failed", async () => {
|
||||
let recoverArgs = null;
|
||||
const teardown = _registerForTest({
|
||||
id: "test.validate-fail",
|
||||
preconditions: () => ({ ok: true }),
|
||||
execute: async () => ({ ok: true, code: "done", worldDelta: { x: 1 } }),
|
||||
validate: () => false,
|
||||
recover: (ctx, result) => {
|
||||
recoverArgs = result;
|
||||
return null;
|
||||
},
|
||||
});
|
||||
try {
|
||||
const res = await runSkill("test.validate-fail", ctx);
|
||||
assert.equal(res.ok, false);
|
||||
assert.equal(res.code, RUNNER_CODES.VALIDATION_FAILED);
|
||||
assert.ok(recoverArgs);
|
||||
assert.equal(recoverArgs.code, RUNNER_CODES.VALIDATION_FAILED);
|
||||
} finally {
|
||||
teardown();
|
||||
}
|
||||
});
|
||||
|
||||
test("result missing code defaults to runner DONE on success", async () => {
|
||||
const teardown = _registerForTest({
|
||||
id: "test.no-code",
|
||||
preconditions: () => ({ ok: true }),
|
||||
execute: async () => ({ ok: true }),
|
||||
});
|
||||
try {
|
||||
const res = await runSkill("test.no-code", ctx);
|
||||
assert.equal(res.ok, true);
|
||||
assert.equal(res.code, RUNNER_CODES.DONE);
|
||||
} finally {
|
||||
teardown();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
// survive.eat — consume the best available food. Picks from the registry-
|
||||
// derived foods() set rather than a hard-coded list, so a 1.20 server
|
||||
// without "glow_berries" won't trip the skill.
|
||||
|
||||
import { eatBestFood } from "../actions.js";
|
||||
import { foods } from "./groups.js";
|
||||
|
||||
export const skill = Object.freeze({
|
||||
id: "survive.eat",
|
||||
title: "Eat best food",
|
||||
timeoutMs: 20_000,
|
||||
preconditions(ctx) {
|
||||
if (!ctx?.bot) return { ok: false, code: "no_bot", detail: "bot missing" };
|
||||
const food = (ctx.snapshot?.food ?? 20);
|
||||
if (food >= 18) return { ok: false, code: "not_hungry", detail: `food=${food}` };
|
||||
const allowed = foods(ctx.bot);
|
||||
if (allowed.size === 0) {
|
||||
return { ok: false, code: "unsupported_version", detail: "no foods in registry" };
|
||||
}
|
||||
const inv = ctx.snapshot?.inventory ?? {};
|
||||
const carrying = Object.keys(inv).some((name) => allowed.has(name));
|
||||
if (!carrying) return { ok: false, code: "no_food_source", detail: "no edible item in inventory" };
|
||||
return { ok: true };
|
||||
},
|
||||
async execute(ctx) {
|
||||
const res = await eatBestFood(ctx.bot);
|
||||
if (res.ok) {
|
||||
return {
|
||||
ok: true,
|
||||
code: "done",
|
||||
detail: res.detail,
|
||||
worldDelta: { ate: res.detail?.ate ?? null },
|
||||
};
|
||||
}
|
||||
const msg = String(res.detail ?? "");
|
||||
const code = msg.includes("no food in inventory")
|
||||
? "no_food_source"
|
||||
: msg.includes("timed out")
|
||||
? "timeout"
|
||||
: "failed";
|
||||
return { ok: false, code, detail: res.detail, worldDelta: null };
|
||||
},
|
||||
validate(ctx, result) {
|
||||
return result.ok && !!result.worldDelta?.ate;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
// Dynamic item/block groups derived from bot.registry. The goal is to never
|
||||
// hard-code a Minecraft version's item table into skill code: skills ask for
|
||||
// "logs" or "planks" or "beds", and this module returns the set of names that
|
||||
// actually exist on the connected server.
|
||||
//
|
||||
// All helpers are pure: given a bot they return a Set<string>. They tolerate
|
||||
// missing registries (returning an empty set) so the skill code can degrade
|
||||
// to `code: "unsupported_version"` rather than crash.
|
||||
|
||||
function isItemRegistry(reg) {
|
||||
return reg && reg.itemsByName && typeof reg.itemsByName === "object";
|
||||
}
|
||||
|
||||
function isBlockRegistry(reg) {
|
||||
return reg && reg.blocksByName && typeof reg.blocksByName === "object";
|
||||
}
|
||||
|
||||
function pickItems(bot, predicate) {
|
||||
const reg = bot?.registry;
|
||||
if (!isItemRegistry(reg)) return new Set();
|
||||
const out = new Set();
|
||||
for (const name of Object.keys(reg.itemsByName)) {
|
||||
if (predicate(name)) out.add(name);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function pickBlocks(bot, predicate) {
|
||||
const reg = bot?.registry;
|
||||
if (!isBlockRegistry(reg)) return new Set();
|
||||
const out = new Set();
|
||||
for (const name of Object.keys(reg.blocksByName)) {
|
||||
if (predicate(name)) out.add(name);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Wood + stem logs of every available species. The `*_stem` suffix covers
|
||||
// crimson/warped logs; the `_log` suffix covers regular trees and pale_oak.
|
||||
export function logs(bot) {
|
||||
return pickBlocks(bot, (n) => n.endsWith("_log") || n.endsWith("_stem"));
|
||||
}
|
||||
|
||||
export function planks(bot) {
|
||||
return pickItems(bot, (n) => n.endsWith("_planks"));
|
||||
}
|
||||
|
||||
export function sticks(bot) {
|
||||
const reg = bot?.registry;
|
||||
const out = new Set();
|
||||
if (isItemRegistry(reg) && reg.itemsByName.stick) out.add("stick");
|
||||
return out;
|
||||
}
|
||||
|
||||
export function beds(bot) {
|
||||
return pickBlocks(bot, (n) => n.endsWith("_bed"));
|
||||
}
|
||||
|
||||
// Conservative food allow-list. We could derive this from
|
||||
// minecraft-data's foodsByName, but that includes spider_eye and other
|
||||
// hazardous items. Until we have an explicit unsafe-food blacklist, keep
|
||||
// the named cooked/raw/farm staples here and intersect with what exists in
|
||||
// the connected server's item registry — so pale_oak-era new items don't
|
||||
// surprise us and pre-1.13 servers don't blow up on missing entries.
|
||||
const FOOD_ALLOWLIST = [
|
||||
"bread",
|
||||
"cooked_beef",
|
||||
"cooked_chicken",
|
||||
"cooked_porkchop",
|
||||
"cooked_mutton",
|
||||
"cooked_rabbit",
|
||||
"cooked_salmon",
|
||||
"cooked_cod",
|
||||
"baked_potato",
|
||||
"apple",
|
||||
"golden_apple",
|
||||
"carrot",
|
||||
"beetroot",
|
||||
"melon_slice",
|
||||
"sweet_berries",
|
||||
"glow_berries",
|
||||
"mushroom_stew",
|
||||
"rabbit_stew",
|
||||
"beetroot_soup",
|
||||
"suspicious_stew",
|
||||
"dried_kelp",
|
||||
"pumpkin_pie",
|
||||
"beef",
|
||||
"chicken",
|
||||
"porkchop",
|
||||
"mutton",
|
||||
];
|
||||
|
||||
export function foods(bot) {
|
||||
const reg = bot?.registry;
|
||||
if (!isItemRegistry(reg)) return new Set();
|
||||
const out = new Set();
|
||||
for (const name of FOOD_ALLOWLIST) {
|
||||
if (reg.itemsByName[name]) out.add(name);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function axes(bot) {
|
||||
const tools = ["wooden_axe", "stone_axe", "iron_axe", "golden_axe", "diamond_axe", "netherite_axe"];
|
||||
const reg = bot?.registry;
|
||||
if (!isItemRegistry(reg)) return new Set();
|
||||
return new Set(tools.filter((n) => reg.itemsByName[n]));
|
||||
}
|
||||
|
||||
export function pickaxes(bot) {
|
||||
const tools = ["wooden_pickaxe", "stone_pickaxe", "iron_pickaxe", "golden_pickaxe", "diamond_pickaxe", "netherite_pickaxe"];
|
||||
const reg = bot?.registry;
|
||||
if (!isItemRegistry(reg)) return new Set();
|
||||
return new Set(tools.filter((n) => reg.itemsByName[n]));
|
||||
}
|
||||
|
||||
export function swords(bot) {
|
||||
const tools = ["wooden_sword", "stone_sword", "iron_sword", "golden_sword", "diamond_sword", "netherite_sword"];
|
||||
const reg = bot?.registry;
|
||||
if (!isItemRegistry(reg)) return new Set();
|
||||
return new Set(tools.filter((n) => reg.itemsByName[n]));
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// Tests for runtime/skills/groups.js. We synthesise fake registries that
|
||||
// stand in for what mineflayer ships via bot.registry — small enough to be
|
||||
// hand-tested across "modern" and "old" Minecraft shapes.
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { logs, planks, sticks, beds, foods, axes, pickaxes, swords } from "./groups.js";
|
||||
|
||||
function makeBot({ items = [], blocks = [] } = {}) {
|
||||
const itemsByName = Object.fromEntries(items.map((name) => [name, { id: 1, name }]));
|
||||
const blocksByName = Object.fromEntries(blocks.map((name) => [name, { id: 1, name }]));
|
||||
return { registry: { itemsByName, blocksByName } };
|
||||
}
|
||||
|
||||
test("logs() picks every _log and _stem block from registry", () => {
|
||||
const bot = makeBot({
|
||||
blocks: ["oak_log", "dark_oak_log", "crimson_stem", "warped_stem", "stone", "dirt"],
|
||||
});
|
||||
const got = logs(bot);
|
||||
assert.deepEqual(
|
||||
Array.from(got).sort(),
|
||||
["crimson_stem", "dark_oak_log", "oak_log", "warped_stem"],
|
||||
);
|
||||
});
|
||||
|
||||
test("logs() returns empty when registry missing", () => {
|
||||
assert.equal(logs(null).size, 0);
|
||||
assert.equal(logs({}).size, 0);
|
||||
assert.equal(logs({ registry: {} }).size, 0);
|
||||
});
|
||||
|
||||
test("planks/sticks/beds match by suffix or exact name", () => {
|
||||
const bot = makeBot({
|
||||
items: ["oak_planks", "stick", "dirt"],
|
||||
blocks: ["red_bed", "white_bed", "stone"],
|
||||
});
|
||||
assert.deepEqual(Array.from(planks(bot)).sort(), ["oak_planks"]);
|
||||
assert.deepEqual(Array.from(sticks(bot)), ["stick"]);
|
||||
assert.deepEqual(Array.from(beds(bot)).sort(), ["red_bed", "white_bed"]);
|
||||
});
|
||||
|
||||
test("foods() intersects the allowlist with the registry", () => {
|
||||
const bot = makeBot({
|
||||
items: ["bread", "apple", "spider_eye", "rotten_flesh", "cooked_beef", "glow_berries"],
|
||||
});
|
||||
const got = foods(bot);
|
||||
// Allowlist members present in this registry only — spider_eye and
|
||||
// rotten_flesh are explicitly NOT in the allowlist and must be excluded.
|
||||
assert.deepEqual(
|
||||
Array.from(got).sort(),
|
||||
["apple", "bread", "cooked_beef", "glow_berries"],
|
||||
);
|
||||
});
|
||||
|
||||
test("foods() returns empty on missing registry", () => {
|
||||
assert.equal(foods(null).size, 0);
|
||||
});
|
||||
|
||||
test("axes/pickaxes/swords scoped to what the version actually ships", () => {
|
||||
const bot = makeBot({
|
||||
items: ["wooden_axe", "stone_axe", "iron_pickaxe", "diamond_sword"],
|
||||
});
|
||||
assert.deepEqual(Array.from(axes(bot)).sort(), ["stone_axe", "wooden_axe"]);
|
||||
assert.deepEqual(Array.from(pickaxes(bot)).sort(), ["iron_pickaxe"]);
|
||||
assert.deepEqual(Array.from(swords(bot)).sort(), ["diamond_sword"]);
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
// Skill substrate. A skill is a small, self-contained, composable unit of
|
||||
// survival behaviour that the scheduler (today: reflex.js) can call with a
|
||||
// uniform contract. The contract — required by every skill in this folder:
|
||||
//
|
||||
// {
|
||||
// id: "namespace.action", // stable, machine-readable, e.g. "gather.logs"
|
||||
// title: "Human label",
|
||||
// timeoutMs: 45_000, // hard ceiling on execute()
|
||||
// preconditions(ctx) -> { ok, code?, detail? }
|
||||
// async execute(ctx, args) -> { ok, code, detail, worldDelta }
|
||||
// validate?(ctx, result) -> boolean // optional gate after execute
|
||||
// recover?(ctx, result) -> any | null // optional follow-up hint
|
||||
// }
|
||||
//
|
||||
// The runSkill() wrapper enforces the timeout, normalises the result shape
|
||||
// (so any caller can rely on the five required fields), runs validate(), and
|
||||
// calls recover() on failure for the scheduler to consume.
|
||||
//
|
||||
// Skills are pure with respect to the runtime — they never read or write
|
||||
// state-store directly; their `worldDelta` is the only way they communicate
|
||||
// observed changes back to the scheduler, which then decides what to log.
|
||||
|
||||
import { info, warn } from "../log.js";
|
||||
|
||||
import { skill as chopLogs } from "./chop-logs.js";
|
||||
import { skill as eat } from "./eat.js";
|
||||
import { skill as wander } from "./wander.js";
|
||||
|
||||
const SKILLS = new Map();
|
||||
|
||||
function register(skill) {
|
||||
if (!skill || typeof skill !== "object") throw new Error("skill: not an object");
|
||||
if (!skill.id || typeof skill.id !== "string") throw new Error("skill: missing id");
|
||||
if (typeof skill.execute !== "function") throw new Error(`skill ${skill.id}: missing execute`);
|
||||
if (typeof skill.preconditions !== "function") throw new Error(`skill ${skill.id}: missing preconditions`);
|
||||
if (SKILLS.has(skill.id)) throw new Error(`skill ${skill.id}: already registered`);
|
||||
SKILLS.set(skill.id, skill);
|
||||
}
|
||||
|
||||
register(chopLogs);
|
||||
register(eat);
|
||||
register(wander);
|
||||
|
||||
export function listSkills() {
|
||||
return Array.from(SKILLS.values()).map((s) => ({
|
||||
id: s.id,
|
||||
title: s.title ?? s.id,
|
||||
timeoutMs: s.timeoutMs ?? 30_000,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getSkill(id) {
|
||||
return SKILLS.get(id) ?? null;
|
||||
}
|
||||
|
||||
// Stable failure codes the wrapper itself can emit. Skills may emit any
|
||||
// additional codes — but these are the ones runSkill produces.
|
||||
export const RUNNER_CODES = Object.freeze({
|
||||
UNKNOWN_SKILL: "unknown_skill",
|
||||
PRECONDITION_FAILED: "precondition_failed",
|
||||
TIMEOUT: "timeout",
|
||||
THREW: "threw",
|
||||
VALIDATION_FAILED: "validation_failed",
|
||||
DONE: "done",
|
||||
});
|
||||
|
||||
function normaliseResult(res, fallbackCode) {
|
||||
const ok = !!res?.ok;
|
||||
return {
|
||||
ok,
|
||||
code: res?.code ?? (ok ? RUNNER_CODES.DONE : fallbackCode ?? "failed"),
|
||||
detail: res?.detail ?? null,
|
||||
worldDelta: res?.worldDelta ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function withTimeout(promise, ms, label) {
|
||||
let timer;
|
||||
const timeout = new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
|
||||
});
|
||||
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
||||
}
|
||||
|
||||
// Drive one skill through its full lifecycle. The caller (typically reflex.js
|
||||
// or, eventually, a higher-level scheduler) decides when to invoke; runSkill
|
||||
// only owns the contract enforcement.
|
||||
export async function runSkill(id, ctx, args = {}) {
|
||||
const skill = SKILLS.get(id);
|
||||
if (!skill) {
|
||||
warn("skill", `unknown skill ${id}`);
|
||||
return { ok: false, code: RUNNER_CODES.UNKNOWN_SKILL, detail: id, worldDelta: null };
|
||||
}
|
||||
|
||||
let pre;
|
||||
try {
|
||||
pre = skill.preconditions(ctx, args) ?? { ok: true };
|
||||
} catch (e) {
|
||||
return {
|
||||
ok: false,
|
||||
code: RUNNER_CODES.PRECONDITION_FAILED,
|
||||
detail: `preconditions threw: ${e.message}`,
|
||||
worldDelta: null,
|
||||
};
|
||||
}
|
||||
if (!pre.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
code: pre.code ?? RUNNER_CODES.PRECONDITION_FAILED,
|
||||
detail: pre.detail ?? "preconditions failed",
|
||||
worldDelta: null,
|
||||
};
|
||||
}
|
||||
|
||||
const timeoutMs = skill.timeoutMs ?? 30_000;
|
||||
let raw;
|
||||
try {
|
||||
raw = await withTimeout(skill.execute(ctx, args), timeoutMs, `skill(${id})`);
|
||||
} catch (e) {
|
||||
const isTimeout = /timed out after/.test(e.message);
|
||||
const result = {
|
||||
ok: false,
|
||||
code: isTimeout ? RUNNER_CODES.TIMEOUT : RUNNER_CODES.THREW,
|
||||
detail: e.message,
|
||||
worldDelta: null,
|
||||
};
|
||||
if (typeof skill.recover === "function") {
|
||||
try {
|
||||
result.recovery = skill.recover(ctx, result) ?? null;
|
||||
} catch (recoverErr) {
|
||||
warn("skill", `${id}.recover threw: ${recoverErr.message}`);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const result = normaliseResult(raw);
|
||||
if (result.ok && typeof skill.validate === "function") {
|
||||
let valid;
|
||||
try {
|
||||
valid = skill.validate(ctx, result);
|
||||
} catch (e) {
|
||||
warn("skill", `${id}.validate threw: ${e.message}`);
|
||||
valid = false;
|
||||
}
|
||||
if (!valid) {
|
||||
const failed = {
|
||||
ok: false,
|
||||
code: RUNNER_CODES.VALIDATION_FAILED,
|
||||
detail: result.detail,
|
||||
worldDelta: result.worldDelta,
|
||||
};
|
||||
if (typeof skill.recover === "function") {
|
||||
try {
|
||||
failed.recovery = skill.recover(ctx, failed) ?? null;
|
||||
} catch (e) {
|
||||
warn("skill", `${id}.recover threw: ${e.message}`);
|
||||
}
|
||||
}
|
||||
return failed;
|
||||
}
|
||||
}
|
||||
if (!result.ok && typeof skill.recover === "function") {
|
||||
try {
|
||||
result.recovery = skill.recover(ctx, result) ?? null;
|
||||
} catch (e) {
|
||||
warn("skill", `${id}.recover threw: ${e.message}`);
|
||||
}
|
||||
}
|
||||
info("skill", `${id} → ${result.code}${result.detail ? ` (${JSON.stringify(result.detail).slice(0, 80)})` : ""}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
// For tests: lets a unit test register a synthetic skill without touching
|
||||
// the production registry. Returns a teardown function.
|
||||
export function _registerForTest(skill) {
|
||||
register(skill);
|
||||
return () => SKILLS.delete(skill.id);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// explore.wander — pick a random nearby point and path to it. Used as a
|
||||
// fallback when gather skills can't find a target nearby — better to keep
|
||||
// the bot moving than to dispatch the same failing skill on every tick.
|
||||
|
||||
import { wander } from "../actions.js";
|
||||
|
||||
export const skill = Object.freeze({
|
||||
id: "explore.wander",
|
||||
title: "Wander to a nearby point",
|
||||
timeoutMs: 45_000,
|
||||
preconditions(ctx) {
|
||||
if (!ctx?.bot) return { ok: false, code: "no_bot", detail: "bot missing" };
|
||||
return { ok: true };
|
||||
},
|
||||
async execute(ctx, args = {}) {
|
||||
const radius = Math.max(6, args.radius ?? 16);
|
||||
const res = await wander(ctx.bot, radius);
|
||||
if (res.ok) {
|
||||
return {
|
||||
ok: true,
|
||||
code: "done",
|
||||
detail: res.detail,
|
||||
worldDelta: { movedTo: res.detail?.to ?? null },
|
||||
};
|
||||
}
|
||||
const msg = String(res.detail ?? "");
|
||||
const code = msg.includes("timed out")
|
||||
? "timeout"
|
||||
: msg.includes("No path")
|
||||
? "no_safe_path"
|
||||
: "failed";
|
||||
return { ok: false, code, detail: res.detail, worldDelta: null };
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user