v0.3.0-rc.1: live skill registry + fast advisor scaffold

Roots out the v0.2.x failure mode: Pi-extracted lessons routinely named
hallucinated skill ids (relocate.surface, choose.safe.surface,
survive.shelter, gather.visible_log, …). All 47 Pi-lessons in the live DB
had applied_count=0 because normalisePreferSkill couldn't find them.

Fix:
1. runtime/skill-registry.js — single source of truth derived from
   skills/index.js. Exports listSkillIds, isRegistered, and a
   prompt-ready block (skillRegistryPrompt) grouped by namespace.
2. Pi prompts (coach/postmortem, coach/reflect) embed the live registry
   with a "USE ONLY THESE, never invent" instruction. Lessons are
   filtered at write-time too — anything not in the registry and not a
   known mode name gets dropped.
3. coach/advice.js — normalisePreferSkill now returns null for unknown
   ids, hardening consult() against any hallucinations that slip
   through. Warn-logged for visibility.

Also lays the LLM substrate for the rest of v0.3.0:

- runtime/llm/provider.js — OpenAI-compatible chat client. Configured
  via PEPA_FAST_LLM_{BASE_URL,API_KEY,MODEL,TIMEOUT_MS}. Safe no-op
  unless API_KEY is set. Supports JSON-mode.
- runtime/coach/fast-advisor.js — tactical advisor tier (scaffold).
  Exposes advise() that asks the fast LLM what to do RIGHT NOW when
  the reflex is wedged/stuck. Rejects hallucinated skill ids using the
  registry. Rate-limited 6/h, 30s cooldown. Not auto-triggered yet —
  wired into reflex in rc.3 (awareness layer).

Tests: 279 green (+24 vs rc.3): 5 registry, 9 provider, 10 advisor.

See dev/v0.3.0/PLAN.md for the full iteration design (manifesto needs
ladder, event-driven awareness, skill pre-emption) and STATUS.md for
shipped/pending tracking.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 17:39:43 +03:00
co-authored by Claude Opus 4.7
parent 865aae1213
commit fcfa2277ba
13 changed files with 1113 additions and 20 deletions
+163
View File
@@ -0,0 +1,163 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { advise, isAvailable, _resetForTest, __testing } from "./fast-advisor.js";
const API_KEY = "PEPA_FAST_LLM_API_KEY";
const MODEL = "PEPA_FAST_LLM_MODEL";
const BASE = "PEPA_FAST_LLM_BASE_URL";
function withEnv(env, fn) {
const prev = {};
for (const k of Object.keys(env)) {
prev[k] = process.env[k];
if (env[k] === undefined) delete process.env[k];
else process.env[k] = env[k];
}
return Promise.resolve(fn()).finally(() => {
for (const [k, v] of Object.entries(prev)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
});
}
function stubFetch(reply) {
const calls = [];
const orig = globalThis.fetch;
globalThis.fetch = async (url, opts) => {
calls.push({ url, opts });
return {
ok: true,
json: async () => ({
choices: [{ message: { content: typeof reply === "string" ? reply : JSON.stringify(reply) } }],
}),
};
};
return { calls, restore() { globalThis.fetch = orig; } };
}
test("advise: not_configured without API key", async () => {
await withEnv({ [API_KEY]: undefined }, async () => {
_resetForTest();
const res = await advise({ reason: "stuck" });
assert.equal(res.ok, false);
assert.equal(res.code, "not_configured");
});
});
test("advise: accepts a registered skill", async () => {
const f = stubFetch({ action: "switch_skill", skill_id: "survive.flee", rationale: "creeper close" });
try {
await withEnv({ [API_KEY]: "k", [MODEL]: "m", [BASE]: "https://x/v1" }, async () => {
_resetForTest();
const res = await advise({
snapshot: { health: 10, food: 18, isDay: true, position: { x: 1, y: 64, z: 1 } },
reason: "wedged_60s",
recentSkillIds: ["explore.far", "explore.far", "explore.far"],
force: true,
});
assert.equal(res.ok, true);
assert.equal(res.action, "switch_skill");
assert.equal(res.skillId, "survive.flee");
assert.match(res.rationale, /creeper/);
// system prompt should mention the live registry
const sent = JSON.parse(f.calls[0].opts.body);
assert.match(sent.messages[0].content, /Valid skill ids/);
assert.match(sent.messages[0].content, /survive\.flee/);
assert.match(sent.messages[1].content, /wedged_60s/);
});
} finally { f.restore(); }
});
test("advise: rejects hallucinated skill id with code=hallucinated_skill", async () => {
const f = stubFetch({ action: "switch_skill", skill_id: "relocate.surface", rationale: "fresh spot" });
try {
await withEnv({ [API_KEY]: "k", [MODEL]: "m" }, async () => {
_resetForTest();
const res = await advise({ reason: "loop", force: true });
assert.equal(res.ok, false);
assert.equal(res.code, "hallucinated_skill");
assert.equal(res.detail, "relocate.surface");
});
} finally { f.restore(); }
});
test("advise: accepts 'continue' and 'wait' without skill_id", async () => {
const f = stubFetch({ action: "continue", rationale: "skill is making slow progress" });
try {
await withEnv({ [API_KEY]: "k", [MODEL]: "m" }, async () => {
_resetForTest();
const res = await advise({ reason: "tick", force: true });
assert.equal(res.ok, true);
assert.equal(res.action, "continue");
});
} finally { f.restore(); }
});
test("advise: rate-limit cooldown blocks rapid calls", async () => {
const f = stubFetch({ action: "continue", rationale: "ok" });
try {
await withEnv({ [API_KEY]: "k", [MODEL]: "m" }, async () => {
_resetForTest();
const r1 = await advise({ reason: "x" });
assert.equal(r1.ok, true);
const r2 = await advise({ reason: "y" });
assert.equal(r2.ok, false);
assert.equal(r2.code, "cooldown");
});
} finally { f.restore(); }
});
test("advise: hourly budget enforced with force=true override", async () => {
const f = stubFetch({ action: "continue", rationale: "ok" });
try {
await withEnv({ [API_KEY]: "k", [MODEL]: "m" }, async () => {
_resetForTest();
for (let i = 0; i < __testing.HOURLY_BUDGET; i++) {
await advise({ reason: `t${i}`, force: true });
}
const over = await advise({ reason: "over" });
assert.equal(over.ok, false);
assert.equal(over.code, "budget_exhausted");
});
} finally { f.restore(); }
});
test("buildSystemPrompt: contains registry block and JSON schema", () => {
const sys = __testing.buildSystemPrompt();
assert.match(sys, /switch_skill/);
assert.match(sys, /Valid skill ids/);
assert.match(sys, /survive\.flee/);
});
test("buildUserPrompt: includes trigger and recent skills", () => {
const u = __testing.buildUserPrompt({
snapshot: { health: 4, food: 3, isDay: false, position: { x: 10, y: 65, z: 10 }, activeSkill: "explore.far" },
reason: "hp_plunge",
recentSkillIds: ["explore.far", "explore.far"],
lessonsTail: [{ text: "do not fight at night" }],
});
assert.match(u, /hp_plunge/);
assert.match(u, /HP: 4/);
assert.match(u, /Recent dispatches: explore\.far → explore\.far/);
assert.match(u, /do not fight at night/);
});
test("formatThreats: empty / formatted", () => {
assert.equal(__testing.formatThreats(undefined), "(none)");
assert.equal(__testing.formatThreats([]), "(none)");
assert.equal(
__testing.formatThreats([{ name: "zombie", distance: 4.3 }, { name: "creeper", distance: 7 }]),
"zombie@4m, creeper@7m",
);
});
test("isAvailable mirrors provider availability", async () => {
await withEnv({ [API_KEY]: undefined }, async () => {
assert.equal(isAvailable(), false);
});
await withEnv({ [API_KEY]: "k" }, async () => {
assert.equal(isAvailable(), true);
});
});