Closes the awareness → LLM → action loop that the rc.1/2/3 sequence
left as a followup. When the bot is wedged, looping, or just suffered
a preempt-then-retry, the reflex fires advise() in the background;
when the recommendation lands it overrides the next dispatch.
Async by design: advise() takes 5-15s on TimeWeb's hosted endpoint —
too slow for a synchronous reflex tick. tickAdvisor() is fire-and-
forget, the result lands on ctx.advisorRecommendation, and the *next*
tick reads and consumes it. Recommendations age out after 60s.
Components:
- runtime/coach/advisor-trigger.js — policy + async fire path
- tickAdvisor(ctx, {plannedSkillId}) checks three triggers:
1. wedged > 60s (no significant move)
2. last 4+ dispatches are the same skill AND it's planned again
3. preempt within last 30s + same skill being retried
- 90s trigger cooldown, single-in-flight guard
- consumeFreshRecommendation(ctx) reads/clears the cache
- runtime/reflex.js — curriculumReflex calls tickAdvisor() every tick
and consumes a fresh recommendation BEFORE dispatching. ctx flag
disableAdvisor=true for tests.
- runtime/bot.js — dispatchAction maintains a rolling 8-slot
reflexCtx.recentSkillIds for the loop-detection trigger.
Token usage:
- runtime/llm/provider.js — normaliseUsage() reads OpenAI/TimeWeb-
style {prompt_tokens, completion_tokens, total_tokens} from the
response. Returned on every complete() result and logged at info
level as "in=Nt/out=Mt".
- runtime/coach/fast-advisor.js — getUsageSnapshot() aggregates
total tokens across all calls in the session.
Measured on live TimeWeb endpoint (gpt-5.4-mini agent):
per call: ~705 input + 45 output = ~750 tokens
rate limit: 6 calls/hour
worst case at full budget: ~108K tokens/day
estimated cost (OpenAI gpt-5-mini reference price): ~$0.60/month
Well within any reasonable budget — model can run hot 24/7.
Smoke verified: scripts/check-timeweb.js probe 4 produces
trigger fired: true (wedged_90s)
recommendation: recovery.tunnel-out
rationale: "Stuck wedged for 90s; exploration is failing."
latency: 5302ms
Tests: 345 green (was 332, +13 advisor-trigger).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
145 lines
5.4 KiB
JavaScript
145 lines
5.4 KiB
JavaScript
// Smoke test for the TIMEWEB_* fast-LLM env vars.
|
|
//
|
|
// Loads .env, asks the model a tiny structured question, prints
|
|
// {ok, latency, code, first 200 chars of reply}. No bot state is
|
|
// touched — this is purely a connectivity check.
|
|
//
|
|
// Usage:
|
|
// node scripts/check-timeweb.js
|
|
|
|
import { config as loadDotenv } from "dotenv";
|
|
loadDotenv();
|
|
|
|
import { complete, isAvailable, getConfig } from "../runtime/llm/provider.js";
|
|
import { advise, getUsageSnapshot, _resetForTest as resetAdvisor } from "../runtime/coach/fast-advisor.js";
|
|
import { tickAdvisor, consumeFreshRecommendation, _resetForTest as resetTrigger } from "../runtime/coach/advisor-trigger.js";
|
|
|
|
function redact(key) {
|
|
if (!key) return "(unset)";
|
|
if (key.length < 12) return "(set, short)";
|
|
return `${key.slice(0, 6)}…${key.slice(-4)} (${key.length} chars)`;
|
|
}
|
|
|
|
async function main() {
|
|
console.log("=== TimeWeb / fast-advisor smoke test ===");
|
|
const cfg = getConfig();
|
|
console.log(`BASE_URL: ${cfg.baseUrl || "(unset)"}`);
|
|
console.log(`API_KEY: ${redact(cfg.apiKey)}`);
|
|
console.log(`MODEL: ${cfg.model || "(unset)"}`);
|
|
console.log(`TIMEOUT: ${cfg.timeoutMs}ms`);
|
|
console.log(`isAvailable: ${isAvailable()}`);
|
|
console.log("");
|
|
|
|
if (!isAvailable()) {
|
|
console.error("ERROR: TIMEWEB_API_KEY not set in .env — aborting.");
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log("→ probe 1: plain prompt, no JSON mode");
|
|
const r1 = await complete({
|
|
system: "Reply in 5 words or less.",
|
|
user: "Say 'pepa hears you'.",
|
|
json: false,
|
|
});
|
|
logResult(r1);
|
|
|
|
console.log("");
|
|
console.log("→ probe 2: JSON mode with a tiny structured request");
|
|
const r2 = await complete({
|
|
system: "Reply with strict JSON only.",
|
|
user: 'Return {"alive": true, "name": "pepa"}',
|
|
json: true,
|
|
});
|
|
logResult(r2);
|
|
|
|
console.log("");
|
|
console.log("→ probe 3: full fast-advisor stack (registry injection + skill validation)");
|
|
resetAdvisor();
|
|
const r3 = await advise({
|
|
snapshot: {
|
|
position: { x: 608, y: 90, z: 91 },
|
|
health: 14, food: 18, isDay: true,
|
|
inventory: { dirt: 4 },
|
|
activeSkill: "explore.far",
|
|
},
|
|
reason: "wedged_60s",
|
|
recentSkillIds: ["explore.far", "explore.far", "explore.far", "explore.far"],
|
|
lessonsTail: [
|
|
{ text: "Если позиция почти не меняется и инвентарь не растёт, прекращай текущий exploration skill." },
|
|
],
|
|
force: true,
|
|
});
|
|
console.log(` ok: ${r3.ok}`);
|
|
console.log(` latency: ${r3.latencyMs}ms`);
|
|
if (r3.ok) {
|
|
console.log(` action: ${r3.action}`);
|
|
console.log(` skillId: ${r3.skillId ?? "(n/a)"}`);
|
|
console.log(` why: ${r3.rationale}`);
|
|
if (r3.usage) {
|
|
console.log(` tokens: in=${r3.usage.in} out=${r3.usage.out} total=${r3.usage.total}`);
|
|
}
|
|
} else {
|
|
console.log(` code: ${r3.code}`);
|
|
console.log(` detail: ${String(r3.detail).slice(0, 200)}`);
|
|
}
|
|
|
|
console.log("");
|
|
console.log("→ probe 4: auto-trigger flow (tickAdvisor → wait → consumeFreshRecommendation)");
|
|
resetAdvisor();
|
|
resetTrigger();
|
|
const ctx = {
|
|
snapshot: { position: { x: 608, y: 90, z: 91 }, health: 14, food: 18, isDay: true,
|
|
inventory: { dirt: 4 }, activeSkill: "explore.far" },
|
|
recentSkillIds: ["explore.far", "explore.far", "explore.far", "explore.far"],
|
|
lastSignificantMoveAt: Date.now() - 90_000,
|
|
};
|
|
const t = tickAdvisor(ctx, { plannedSkillId: "explore.far" });
|
|
console.log(` trigger fired: ${t.fired} (${t.reason})`);
|
|
// wait up to 25s for async advise to land
|
|
const waitStart = Date.now();
|
|
while (!ctx.advisorRecommendation && Date.now() - waitStart < 25_000) {
|
|
await new Promise((r) => setTimeout(r, 200));
|
|
}
|
|
const consumed = consumeFreshRecommendation(ctx);
|
|
if (consumed) {
|
|
console.log(` recommendation: ${consumed.skillId}`);
|
|
console.log(` rationale: ${consumed.rationale}`);
|
|
console.log(` latency: ${consumed.latencyMs}ms`);
|
|
if (consumed.usage) {
|
|
console.log(` tokens: in=${consumed.usage.in} out=${consumed.usage.out} total=${consumed.usage.total}`);
|
|
}
|
|
} else {
|
|
console.log(` no recommendation (timeout or non-switch action)`);
|
|
}
|
|
|
|
console.log("");
|
|
console.log("=== Usage budget summary ===");
|
|
const usage = getUsageSnapshot();
|
|
console.log(` calls (last hour): ${usage.callsLastHour}/${usage.hourlyBudget}`);
|
|
console.log(` calls total: ${usage.callsTotal}`);
|
|
console.log(` tokens in (total): ${usage.tokensInTotal}`);
|
|
console.log(` tokens out (total): ${usage.tokensOutTotal}`);
|
|
// Rough cost estimate for context — TimeWeb pricing unknown, OpenAI
|
|
// gpt-5-mini hypothetical: $0.15/M input + $0.60/M output.
|
|
const estUsd = (usage.tokensInTotal * 0.15 + usage.tokensOutTotal * 0.60) / 1_000_000;
|
|
console.log(` est. cost (OpenAI gpt-5-mini pricing): $${estUsd.toFixed(6)}`);
|
|
console.log(` per-call avg in: ${Math.round(usage.tokensInTotal / Math.max(1, usage.callsTotal))}t`);
|
|
console.log(` hourly @ budget: ${Math.round(usage.tokensInTotal / Math.max(1, usage.callsTotal)) * usage.hourlyBudget}t in / ${Math.round(usage.tokensOutTotal / Math.max(1, usage.callsTotal)) * usage.hourlyBudget}t out`);
|
|
}
|
|
|
|
function logResult(r) {
|
|
console.log(` ok: ${r.ok}`);
|
|
console.log(` latency: ${r.latencyMs}ms`);
|
|
if (!r.ok) {
|
|
console.log(` code: ${r.code}`);
|
|
console.log(` detail: ${String(r.detail).slice(0, 400)}`);
|
|
return;
|
|
}
|
|
console.log(` reply: ${typeof r.text === "string" ? r.text.slice(0, 200) : JSON.stringify(r.text).slice(0, 200)}`);
|
|
}
|
|
|
|
main().catch((e) => {
|
|
console.error("UNHANDLED:", e?.message ?? e);
|
|
process.exit(2);
|
|
});
|