From 1b275ed5e6e575be1dec983f0e168a0b56bf9c0b Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Tue, 9 Jun 2026 00:14:49 +0200 Subject: [PATCH] Give reasoning models room; skip the advisor's self-contradictory ask MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/features/assistant/useEncounterAdvisor.ts | 7 ++++++- src/lib/llm/client.ts | 8 +++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/features/assistant/useEncounterAdvisor.ts b/src/features/assistant/useEncounterAdvisor.ts index eae059e..5571e9a 100644 --- a/src/features/assistant/useEncounterAdvisor.ts +++ b/src/features/assistant/useEncounterAdvisor.ts @@ -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 " 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(getLlmConfig(), { system: prompt.system, user: prompt.user, schema: balanceSuggestionSchema }); if (res.ok && 'data' in res) { diff --git a/src/lib/llm/client.ts b/src/lib/llm/client.ts index e8945db..c098bb7 100644 --- a/src/lib/llm/client.ts +++ b/src/lib/llm/client.ts @@ -1,7 +1,10 @@ import type { CompleteOptions, LlmConfig, LlmErrorKind, LlmResult } from './types'; const DEFAULT_TIMEOUT = 30_000; -const DEFAULT_MAX_TOKENS = 1024; +// Generous so reasoning models (e.g. DeepSeek, whose chain-of-thought counts +// against the completion budget) have room to finish thinking AND emit the JSON. +// A small cap truncates them mid-reasoning, leaving an empty `content`. +const DEFAULT_MAX_TOKENS = 4096; function err(error: LlmErrorKind, message: string): LlmResult { return { ok: false, error, message }; @@ -176,6 +179,9 @@ export async function complete(cfg: LlmConfig, opts: CompleteOptions): Pro const text = extractText(cfg.provider, json); if (!wantJson) return { ok: true, text }; + if (text.trim() === '') { + return err('parse', 'The model returned an empty response — it likely ran out of output tokens before answering (raise max tokens, or the model spent its whole budget reasoning).'); + } const parsed = tryParseJson(text); if (parsed === undefined) return err('parse', 'The model did not return valid JSON.'); const result = opts.schema!.safeParse(parsed);