From d29699f5657f237b8750221e28e6d0ad79e4f6e8 Mon Sep 17 00:00:00 2001 From: DanTheMan181 <283874042+Doorman11991@users.noreply.github.com> Date: Fri, 29 May 2026 20:24:21 -0700 Subject: [PATCH] fix(#49): surface final answers stuck in reasoning_content (v1.5.1) --- CHANGELOG.md | 22 +++++++++++++++ bin/smallcode.js | 18 +++++++++++- package-lock.json | 4 +-- package.json | 2 +- src/session/message_normalizer.js | 29 ++++++++++++++++++- test/message_normalizer.test.js | 47 +++++++++++++++++++++++++++++++ 6 files changed, 117 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44737ed..5b30b12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## [1.5.1] - 2026-05-29 + +### fix: surface final answers stuck in reasoning_content (#49) + +Reasoning models served with a split reasoning channel — vLLM with +`--reasoning-parser qwen3`, DeepSeek R1, and qwen3.x family — sometimes +return their final answer in `reasoning_content` with an empty `content` +and `finish_reason: stop` (no tool call). The agent loop saw an empty turn +after its read/tool calls and exited with no visible output ("No output!"), +even though the server logs showed the model generating tokens the whole +time. + +- New `src/session/message_normalizer.js#recoverReasoningAnswer()` promotes + `reasoning_content` to `content` when there's no tool call and `content` + is empty, so the normal final-answer rendering path surfaces it. +- Wired into the agent loop (`bin/smallcode.js`) right after tool-call + recovery. Opt-out via `SMALLCODE_REASONING_FALLBACK=false`. +- Verified: deterministic recovery of the exact issue #49 wire shape, plus + a full agent run against a qwen3.6-35b reasoning model producing correct, + visible output with no regression on healthy (content-populated) turns. +- Test coverage: 6 new cases in `test/message_normalizer.test.js`. + ## [1.5.0] - 2026-05-29 ### feat: TDD harness — Red→Green→Refactor state machine (#68) diff --git a/bin/smallcode.js b/bin/smallcode.js index 857bddb..7a10ed2 100755 --- a/bin/smallcode.js +++ b/bin/smallcode.js @@ -90,7 +90,7 @@ const { PluginLoader } = require('../src/plugins/loader'); const { SkillManager } = require('../src/plugins/skills'); const { SessionStore } = require('../src/session/persistence'); const { resolveReferences, formatReferencesForPrompt } = require('../src/session/references'); -const { consolidateSystemMessages } = require('../src/session/message_normalizer'); +const { consolidateSystemMessages, recoverReasoningAnswer } = require('../src/session/message_normalizer'); const { TokenTracker } = require('../src/session/tokens'); const { UndoStack } = require('../src/session/undo'); const { shouldInjectGitContext, getGitDiffContext } = require('../src/session/git_context'); @@ -998,6 +998,22 @@ async function runAgentLoop(userMessage, config) { } } catch {} + // Reasoning-channel answer recovery (issue #49). Some servers — vLLM with + // `--reasoning-parser qwen3`, DeepSeek R1, and other reasoning models — + // put the model's FINAL answer in `reasoning_content` and leave `content` + // empty when finish_reason is `stop` (no tool call to extract). Without + // this, the agent loop sees an empty turn after a few read_file calls and + // exits with no output ("No output!"). If there's no tool call and content + // is empty but reasoning_content has text, promote it to content so the + // normal final-answer rendering path surfaces it. Disable with + // SMALLCODE_REASONING_FALLBACK=false. + try { + const fallbackDisabled = String(process.env.SMALLCODE_REASONING_FALLBACK || 'true').toLowerCase() === 'false'; + if (!fallbackDisabled && recoverReasoningAnswer(message)) { + if (_fullscreenRef) _fullscreenRef.addTool('reasoning', 'ok', 'recovered answer from reasoning_content'); + } + } catch {} + // Extract and optionally display thinking content before it enters history. // Reasoning models (Qwen3, DeepSeek R1, Gemma 4) emit ... // blocks before their answer. We: diff --git a/package-lock.json b/package-lock.json index d02f7d1..936ebf8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "smallcode", - "version": "1.5.0", + "version": "1.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "smallcode", - "version": "1.5.0", + "version": "1.5.1", "license": "MIT", "dependencies": { "bonescript-compiler": "0.14.0", diff --git a/package.json b/package.json index 7da83bd..f87899a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "smallcode", - "version": "1.5.0", + "version": "1.5.1", "description": "AI coding agent optimized for small LLMs (8B-35B parameters)", "main": "src/api/index.js", "bin": { diff --git a/src/session/message_normalizer.js b/src/session/message_normalizer.js index cc44b25..7c5730c 100644 --- a/src/session/message_normalizer.js +++ b/src/session/message_normalizer.js @@ -72,4 +72,31 @@ function consolidateSystemMessages(messages) { return [merged, ...rest]; } -module.exports = { consolidateSystemMessages }; +/** + * Recover a final answer that a reasoning model placed in `reasoning_content` + * while leaving `content` empty (issue #49). Some servers — vLLM with + * `--reasoning-parser qwen3`, DeepSeek R1, and other reasoning models — emit + * the model's final prose answer in the `reasoning_content` channel and leave + * `content` empty when there is no tool call. The agent loop would otherwise + * see an empty turn and exit with no output. + * + * Mutates `message` in place: when it has no tool calls, an empty `content`, + * and a non-empty `reasoning_content`, the reasoning text is promoted to + * `content`. Returns true if a promotion happened, false otherwise. + * + * @param {object} message OpenAI-style assistant message. + * @returns {boolean} + */ +function recoverReasoningAnswer(message) { + if (!message || typeof message !== 'object') return false; + const hasToolCalls = Array.isArray(message.tool_calls) && message.tool_calls.length > 0; + if (hasToolCalls) return false; + const contentEmpty = !(typeof message.content === 'string' && message.content.trim()); + if (!contentEmpty) return false; + const reasoning = typeof message.reasoning_content === 'string' ? message.reasoning_content.trim() : ''; + if (!reasoning) return false; + message.content = reasoning; + return true; +} + +module.exports = { consolidateSystemMessages, recoverReasoningAnswer }; diff --git a/test/message_normalizer.test.js b/test/message_normalizer.test.js index e2b9f52..bddba9e 100644 --- a/test/message_normalizer.test.js +++ b/test/message_normalizer.test.js @@ -98,3 +98,50 @@ test('handles only-system input (collapses to one)', () => { test('empty array is a no-op', () => { assert.deepEqual(consolidateSystemMessages([]), []); }); + + +// ─── Issue #49: recover a final answer from reasoning_content ─────────────── + +const { recoverReasoningAnswer } = require('../src/session/message_normalizer'); + +test('promotes reasoning_content to content when content is empty and no tool calls', () => { + const msg = { role: 'assistant', content: '', reasoning_content: 'Here are the bugs I found: ...' }; + const promoted = recoverReasoningAnswer(msg); + assert.equal(promoted, true); + assert.equal(msg.content, 'Here are the bugs I found: ...'); +}); + +test('does not touch content when it is already non-empty', () => { + const msg = { role: 'assistant', content: 'real answer', reasoning_content: 'thinking...' }; + const promoted = recoverReasoningAnswer(msg); + assert.equal(promoted, false); + assert.equal(msg.content, 'real answer'); +}); + +test('does not promote when there are tool calls', () => { + const msg = { + role: 'assistant', + content: '', + reasoning_content: 'I will read the file', + tool_calls: [{ id: '1', type: 'function', function: { name: 'read_file', arguments: '{}' } }], + }; + const promoted = recoverReasoningAnswer(msg); + assert.equal(promoted, false); + assert.equal(msg.content, ''); +}); + +test('no-op when reasoning_content is empty/whitespace', () => { + const msg = { role: 'assistant', content: '', reasoning_content: ' ' }; + assert.equal(recoverReasoningAnswer(msg), false); +}); + +test('no-op when reasoning_content is absent', () => { + const msg = { role: 'assistant', content: '' }; + assert.equal(recoverReasoningAnswer(msg), false); +}); + +test('handles null/garbage input safely', () => { + assert.equal(recoverReasoningAnswer(null), false); + assert.equal(recoverReasoningAnswer(undefined), false); + assert.equal(recoverReasoningAnswer({}), false); +});