feat(v0.3.0): auto-trigger fast-advisor + token usage tracking

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>
This commit is contained in:
2026-05-27 18:44:30 +03:00
co-authored by Claude Opus 4.7
parent 602060d857
commit bc381b2a4b
9 changed files with 499 additions and 9 deletions
+48 -1
View File
@@ -11,7 +11,8 @@ import { config as loadDotenv } from "dotenv";
loadDotenv();
import { complete, isAvailable, getConfig } from "../runtime/llm/provider.js";
import { advise, _resetForTest as resetAdvisor } from "../runtime/coach/fast-advisor.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)";
@@ -74,10 +75,56 @@ async function main() {
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) {