Files
pepa-pi-bot/runtime/coach/fast-advisor.js
T
mayatnikovandClaude Opus 4.7 ecbdd05058 chore(v0.3.0): rename fast-LLM env vars to TIMEWEB_* (match other projects)
Aligns with the user's other repos (proso) which use TIMEWEB_API_GROK /
TIMEWEB_URL_GROK. Single naming convention across projects avoids the
'which env var was it for this repo' mental tax.

  PEPA_FAST_LLM_BASE_URL → TIMEWEB_BASE_URL
  PEPA_FAST_LLM_API_KEY  → TIMEWEB_API_KEY
  PEPA_FAST_LLM_MODEL    → TIMEWEB_MODEL
  PEPA_FAST_LLM_TIMEOUT_MS → TIMEWEB_TIMEOUT_MS

Provider still works with any OpenAI-compatible endpoint — TimeWeb is
the default but the variable name doesn't lock us in. Tests + docs +
.env / .env.example updated.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 18:09:04 +03:00

168 lines
6.0 KiB
JavaScript

// Fast tactical advisor — second LLM tier, parallel to Pi.
//
// Pi (the CLI coach) is great for deep post-mortems and 30-min reflection,
// but it's slow (5-15s) and rate-limited. When the reflex detects the bot
// is wedged, stuck, or just took an environment shock (forced teleport,
// HP plunge, hostile spawn), we want a sub-2-second "what do I do?"
// answer from a cheap, hosted model. That's this module.
//
// In rc.1 this is a scaffold: complete() + advise() + rate-limiting +
// integration tests, but no auto-trigger from the reflex yet. rc.3 wires
// the trigger paths (awareness layer) into here.
//
// The advisor MUST return a JSON shape whose `prefer_skill` field is a
// real, registered skill id — anything else is rejected. The system
// prompt embeds the live registry so the model has the source of truth.
import { complete, isAvailable as llmAvailable } from "../llm/provider.js";
import { isRegistered, skillRegistryPrompt } from "../skill-registry.js";
import { info, warn } from "../log.js";
const HOURLY_BUDGET = 6;
const COOLDOWN_MS = 30_000;
let _callTimes = [];
let _lastCallAt = 0;
export function isAvailable() {
return llmAvailable();
}
export function _resetForTest() {
_callTimes = [];
_lastCallAt = 0;
}
/**
* advise({ snapshot, reason, recentSkillIds, lessonsTail }) →
* { ok: true, action: 'switch_skill'|'continue'|'wait', skillId?, rationale, raw, latencyMs }
* | { ok: false, code, detail, latencyMs }
*
* `reason` is a free-text trigger ("wedged_60s", "forced_move",
* "hp_plunge", "stuck_3_dispatches"). It goes verbatim into the prompt
* so the model can tailor its advice.
*/
export async function advise({
snapshot,
reason = "unknown",
recentSkillIds = [],
lessonsTail = [],
force = false,
} = {}) {
if (!isAvailable()) {
return { ok: false, code: "not_configured", detail: "set TIMEWEB_API_KEY", latencyMs: 0 };
}
const now = Date.now();
_callTimes = _callTimes.filter((t) => t > now - 3600_000);
if (!force && _callTimes.length >= HOURLY_BUDGET) {
return { ok: false, code: "budget_exhausted", detail: `${_callTimes.length}/${HOURLY_BUDGET} per hour`, latencyMs: 0 };
}
if (!force && now - _lastCallAt < COOLDOWN_MS) {
return { ok: false, code: "cooldown", detail: `${Math.round((COOLDOWN_MS - (now - _lastCallAt)) / 1000)}s`, latencyMs: 0 };
}
const system = buildSystemPrompt();
const user = buildUserPrompt({ snapshot, reason, recentSkillIds, lessonsTail });
_callTimes.push(now);
_lastCallAt = now;
const res = await complete({ system, user, json: true });
if (!res.ok) {
warn("advisor", `complete failed: ${res.code} (${res.detail})`);
return { ok: false, code: res.code, detail: res.detail, latencyMs: res.latencyMs };
}
const parsed = res.text;
if (!parsed || typeof parsed !== "object") {
return { ok: false, code: "bad_shape", detail: "no object in reply", latencyMs: res.latencyMs };
}
const action = String(parsed.action ?? "").toLowerCase();
const skillId = parsed.skill_id ?? parsed.prefer_skill ?? null;
const rationale = parsed.rationale ?? parsed.reason ?? "";
if (action === "switch_skill") {
if (!skillId || !isRegistered(skillId)) {
warn("advisor", `rejected hallucinated skill "${skillId}"`);
return {
ok: false,
code: "hallucinated_skill",
detail: skillId ?? "(null)",
rationale,
raw: parsed,
latencyMs: res.latencyMs,
};
}
info("advisor", `switch_skill → ${skillId} (${rationale.slice(0, 80)})`);
return {
ok: true,
action: "switch_skill",
skillId,
rationale,
raw: parsed,
latencyMs: res.latencyMs,
};
}
if (action === "continue" || action === "wait") {
info("advisor", `${action} (${rationale.slice(0, 80)})`);
return { ok: true, action, rationale, raw: parsed, latencyMs: res.latencyMs };
}
return { ok: false, code: "bad_action", detail: action || "missing", raw: parsed, latencyMs: res.latencyMs };
}
function buildSystemPrompt() {
return [
"You are the tactical advisor for pepa, an autonomous Minecraft survival bot.",
"You are called when the bot's reflex layer detects something wrong (wedged, stuck,",
"forced move, HP plunge). Your job: produce a single fast decision.",
"",
"Reply STRICTLY with a JSON object:",
'{',
' "action": "switch_skill" | "continue" | "wait",',
' "skill_id": "<registered skill id or null>",',
' "rationale": "<≤25 words explaining why>"',
'}',
"",
"Rules:",
'- "switch_skill" REQUIRES skill_id to be one of the registered ids below.',
'- "continue" means current skill is fine, just give it more time.',
'- "wait" means stop dispatching for ~10s (e.g. waiting for night to pass).',
'- If unsure, return "continue".',
"",
skillRegistryPrompt({ limit: 1800 }),
].join("\n");
}
function buildUserPrompt({ snapshot, reason, recentSkillIds, lessonsTail }) {
const pos = snapshot?.position;
const inv = snapshot?.inventory ? Object.keys(snapshot.inventory).slice(0, 10).join(", ") : "(empty)";
const recent = (recentSkillIds ?? []).slice(-8).join(" → ") || "(none)";
const lessons = (lessonsTail ?? []).slice(0, 4).map((l) => ` - ${l.text ?? l}`).join("\n");
return [
`Trigger: ${reason}`,
`Position: ${pos ? `(${Math.round(pos.x)}, ${Math.round(pos.y)}, ${Math.round(pos.z)})` : "?"}`,
`HP: ${snapshot?.health ?? "?"} food: ${snapshot?.food ?? "?"} day: ${snapshot?.isDay ? "yes" : "no"}`,
`Active skill: ${snapshot?.activeSkill ?? "(idle)"}`,
`Recent dispatches: ${recent}`,
`Inventory keys: ${inv}`,
`Nearby threats: ${formatThreats(snapshot?.threats)}`,
`No-progress reason: ${snapshot?.noProgressReason ?? "(none)"}`,
"",
lessons ? `Relevant lessons:\n${lessons}\n` : "",
"What should the bot do RIGHT NOW? Return the JSON decision.",
].filter(Boolean).join("\n");
}
function formatThreats(threats) {
if (!Array.isArray(threats) || threats.length === 0) return "(none)";
return threats.slice(0, 3).map((t) => `${t.name ?? "?"}@${Math.round(t.distance ?? 0)}m`).join(", ");
}
// Test exports
export const __testing = { buildSystemPrompt, buildUserPrompt, formatThreats, HOURLY_BUDGET, COOLDOWN_MS };