Give reasoning models room; skip the advisor's self-contradictory ask

DeepSeek is a reasoning model — its chain-of-thought counts against the
completion budget. The 1024 max_tokens default let it burn the whole budget
"thinking" and get cut off (finish_reason: length) with an empty content, which
read as a parse failure. Raise DEFAULT_MAX_TOKENS to 4096 so it can finish
reasoning AND emit the JSON. Also return a clear error on empty responses.

The spiral was triggered by a contradictory prompt: the fight was already
"deadly" yet the advisor still asked the model to "add creatures to reach
Deadly." Skip the LLM when the target tier isn't actually harder than the
current one and let the deterministic path ease the over-tuned fight instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 00:14:49 +02:00
parent 53854deb81
commit 1b275ed5e6
2 changed files with 13 additions and 2 deletions
@@ -108,6 +108,11 @@ export function useEncounterAdvisor(campaign: Campaign, encounter: Encounter) {
const existing: ReinforceMonster[] = monsterCombatants.map((c) => ({ name: c.name, cr: c.cr, level: c.level }));
const current = computeBudget(campaign.system, partyLevels, existing.map((m) => ({ cr: m.cr, level: m.level }))).difficulty;
const target = nextTarget(campaign.system, current);
// When the fight is already at (or above) the hardest tier, "add creatures to
// reach <target>" is a contradiction — it makes reasoning models spiral and burn
// their whole token budget. Only ask the AI when the target is genuinely harder;
// otherwise fall through to the deterministic path (which eases an over-tuned fight).
const targetIsHarder = (ORDINAL[target.toLowerCase()] ?? 99) > (ORDINAL[current.toLowerCase()] ?? -1);
// Candidates the AI may choose from: the creatures already in the fight
// (so it can say "add another goblin") plus level-appropriate bestiary picks.
@@ -125,7 +130,7 @@ export function useEncounterAdvisor(campaign: Campaign, encounter: Encounter) {
const candidates = [...existingCands, ...poolCands];
if (candidates.length === 0) { setMessage('No suitable creatures found for this party.'); setState('error'); return; }
if (canUseLlm) {
if (canUseLlm && targetIsHarder) {
const prompt = buildBalancePrompt(ctx, { difficulty: current, targetDifficulty: target, candidates });
const res = await complete<BalanceSuggestion>(getLlmConfig(), { system: prompt.system, user: prompt.user, schema: balanceSuggestionSchema });
if (res.ok && 'data' in res) {