diff --git a/CHANGELOG.md b/CHANGELOG.md index 88a781bd..8650a5d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,35 @@ # Changelog +## [Unreleased] + +### fix: /version slash command + tool-call extraction for qwen2.5-coder + +Two reported issues fixed. + +- **Issue #40 — `/version` returns "Unknown"** — `/version` (and `/v`) now + print the SmallCode version, package description, and Node/platform info. + Sourced from `package.json` so the value can never drift. Added to the + `/help` listing too. +- **Issue #36 — qwen2.5-coder:14b spills tool JSON into chat** — added a + defensive tool-call extractor (`src/tools/tool_call_extractor.js`) that + recovers tool invocations the model emitted as text instead of as a + structured `tool_calls` field. Wired into `bin/smallcode.js`, + `src/api/index.js`, and `src/session/parallel_executor.js` so all three + entry points benefit. Recognises four shapes: + - `{...}` (Hermes / qwen native template) + - `` ```json ... ``` `` and `` ```tool_call ... ``` `` fences + - Bare leading JSON object/array + - `{ "function": { "name", "arguments" } }` and `{ "tool", "args" }` forms + - Tolerates trailing commas, which qwen sometimes emits. + + Conservative by design — calls referencing unknown tool names are left in + the chat content rather than executed. + + Coverage: `.test-workspace/tool_call_extractor_test.js` (10 cases) and + `.test-workspace/version_command_test.js` (3 cases). + +--- + ## [1.0.2] - 2026-05-22 ### fix: empty tools array + ~/.smallcode/skills/ support diff --git a/README.md b/README.md index 795e38b9..db1bf3c4 100644 --- a/README.md +++ b/README.md @@ -260,6 +260,7 @@ npm run bench:tools | `/skill` | Manage reusable skills | | `/plugin` | Install/manage plugins | | `/sessions` | List/resume saved sessions | +| `/version`, `/v` | Show SmallCode version + Node/platform | | `/help` | Show all commands | ## Observability diff --git a/bin/commands.js b/bin/commands.js index f3ac3afb..2ee282a2 100644 --- a/bin/commands.js +++ b/bin/commands.js @@ -728,6 +728,24 @@ module.exports = function createCommandHandler(config, conversationHistory, impr return; } + case '/version': + case '/v': { + // Read version from package.json (single source of truth). + try { + const pkg = require('../package.json'); + console.log(''); + console.log(` ${chalk.bold('SmallCode')} ${chalk.cyan('v' + pkg.version)}`); + if (pkg.description) console.log(` ${chalk.gray(pkg.description)}`); + console.log(` ${chalk.gray('Node ' + process.version + ' on ' + process.platform + '/' + process.arch)}`); + console.log(''); + } catch (e) { + console.log(chalk.gray(' Version info unavailable: ' + e.message)); + console.log(''); + } + rl.prompt(); + return; + } + case '/help': console.log(''); console.log(chalk.bold(' Commands')); @@ -754,6 +772,7 @@ module.exports = function createCommandHandler(config, conversationHistory, impr console.log(` ${chalk.cyan('/trace')} ${chalk.gray('View/export execution traces')}`); console.log(` ${chalk.cyan('/eval')} ${chalk.gray('Run prompt evaluation')}`); console.log(` ${chalk.cyan('/clear')} ${chalk.gray('Reset entire session')}`); + console.log(` ${chalk.cyan('/version')} ${chalk.gray('Show SmallCode version')}`); console.log(` ${chalk.cyan('/quit')} ${chalk.gray('Exit SmallCode')}`); console.log(''); rl.prompt(); diff --git a/bin/smallcode.js b/bin/smallcode.js index b54908b1..f79f449c 100755 --- a/bin/smallcode.js +++ b/bin/smallcode.js @@ -938,6 +938,18 @@ async function runAgentLoop(userMessage, config) { const message = response.choices?.[0]?.message; if (!message) break; + // Defensive recovery: some local models (qwen2.5-coder, hermes, llama3 + // GGUFs) emit tool calls as JSON inside `content` instead of populating + // structured `tool_calls`. Try to lift them into the proper field + // before any downstream logic looks at the message. Issue #36. + try { + const { extractFromMessage } = require('../src/tools/tool_call_extractor'); + const r = extractFromMessage(message, getAllTools(config, currentToolCategory)); + if (r.patched && _fullscreenRef) { + _fullscreenRef.addTool('tool_call', 'ok', `recovered ${r.addedCalls} from text content`); + } + } catch {} + // Truncate excessive thinking content before it enters conversation history. // Reasoning models can ignore the soft budget and emit 50KB of thinking // loops. We hard-cap it here so the next turn doesn't include a wall of diff --git a/src/api/index.js b/src/api/index.js index 3ddf8846..010e78b6 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -79,6 +79,13 @@ class SmallCode extends EventEmitter { const message = response.choices?.[0]?.message; if (!message) break; + // Recover tool calls embedded in text content (qwen2.5-coder etc.) + // See src/tools/tool_call_extractor.js + issue #36. + try { + const { extractFromMessage } = require('../tools/tool_call_extractor'); + extractFromMessage(message, this._getTools()); + } catch {} + // Track token usage if (response.usage) { result.tokensUsed.input += response.usage.prompt_tokens || 0; diff --git a/src/session/parallel_executor.js b/src/session/parallel_executor.js index cd8e72e9..e2377426 100644 --- a/src/session/parallel_executor.js +++ b/src/session/parallel_executor.js @@ -91,6 +91,14 @@ async function executeBatch(batch, planSteps, systemMessages, chatCompletionFn, const choice = response?.choices?.[0]?.message; if (!choice) break; + // Recover tool calls embedded in text content (qwen2.5-coder etc.). + // Issue #36 — the agent loop in bin/smallcode.js does the same. + try { + const { extractFromMessage } = require('../tools/tool_call_extractor'); + const tools = (typeof ctx?.getAllTools === 'function' ? ctx.getAllTools(config) : null) || []; + extractFromMessage(choice, tools); + } catch {} + finalContent = choice.content || ''; const toolCalls = choice.tool_calls || []; diff --git a/src/tools/tool_call_extractor.js b/src/tools/tool_call_extractor.js new file mode 100644 index 00000000..88c75f08 --- /dev/null +++ b/src/tools/tool_call_extractor.js @@ -0,0 +1,187 @@ +// SmallCode — Tool Call Extractor +// +// Some local models (notably qwen2.5-coder, hermes, llama3-instruct via Ollama, +// and various GGUFs through llama.cpp) do not always emit structured +// `tool_calls` over the OpenAI-compatible endpoint. Instead, they paste the +// tool invocation as JSON text inside `message.content`. +// +// Symptoms reported in the wild (e.g. issue #36 — qwen2.5-coder:14b on Ollama): +// • User asks "what files are in this directory?" +// • Model returns a chat message whose content is literally a JSON blob: +// {"name":"shell","arguments":{"cmd":"ls"}} +// or sometimes a bare ```json fenced block, or even just `{ "name": ... }`. +// • The agent loop sees no `tool_calls`, treats the JSON as chat output, +// and the user sees the raw JSON instead of the tool running. +// +// This module is a defensive recovery layer. Given the model's `message` +// object and the list of available tool schemas, it tries to detect +// embedded tool invocations and rewrite the message in-place to use the +// proper `tool_calls` shape — so the rest of the agent loop can treat +// every model the same way. +// +// Recognised formats (in priority order): +// 1. {...} ← Hermes / qwen2.5 native +// 2. ```json ... ``` (fenced code block) ← qwen-coder / generic +// 3. ```tool_call ... ``` ← some llama3 fine-tunes +// 4. Bare JSON object at the start of content +// +// All formats expect the JSON to be of shape: +// { "name": "", "arguments": } +// or the OpenAI variant: +// { "function": { "name": "", "arguments": "" } } +// or an array of either. +// +// Conservative by design: if the JSON doesn't reference a known tool, we +// leave the content alone — better to show the user a JSON blob than to +// invent tool calls. + +'use strict'; + +// Match ... (qwen / hermes). Multiline + non-greedy. +const TOOL_CALL_TAG_RE = /\s*([\s\S]*?)\s*<\/tool[_\-]?call>/gi; + +// Match ```json ... ``` and ```tool_call ... ``` fences. +const FENCED_RE = /```(?:json|tool_?call)?\s*\n?([\s\S]*?)\n?```/gi; + +// Match a bare JSON object/array at the very start of content (whitespace ok). +const LEADING_JSON_RE = /^\s*([{\[][\s\S]+[}\]])\s*$/; + +/** + * @param {object} message OpenAI-style choice.message; has .content + maybe .tool_calls + * @param {Array} toolSchemas Same shape passed to the model: [{ function: { name, ... } }, ...] + * @returns {{ patched: boolean, addedCalls: number }} + * + * Mutates `message` in place when extraction succeeds. + */ +function extractFromMessage(message, toolSchemas) { + if (!message) return { patched: false, addedCalls: 0 }; + // Already has structured tool_calls — leave it alone. + if (Array.isArray(message.tool_calls) && message.tool_calls.length > 0) { + return { patched: false, addedCalls: 0 }; + } + const content = typeof message.content === 'string' ? message.content : ''; + if (!content) return { patched: false, addedCalls: 0 }; + + const known = new Set(); + if (Array.isArray(toolSchemas)) { + for (const t of toolSchemas) { + const n = t?.function?.name || t?.name; + if (typeof n === 'string') known.add(n); + } + } + + const calls = []; + const consumedRanges = []; // [start, end) of content we transferred into tool_calls + + // 1. Tagged tool calls — strongest signal. + for (const m of content.matchAll(TOOL_CALL_TAG_RE)) { + const parsed = _safeParseAny(m[1]); + for (const tc of _normalize(parsed, known)) calls.push(tc); + if (parsed) consumedRanges.push([m.index, m.index + m[0].length]); + } + + // 2. Fenced JSON blocks. Skipped if we already got tagged calls. + if (calls.length === 0) { + for (const m of content.matchAll(FENCED_RE)) { + const parsed = _safeParseAny(m[1]); + const normalized = _normalize(parsed, known); + if (normalized.length > 0) { + for (const tc of normalized) calls.push(tc); + consumedRanges.push([m.index, m.index + m[0].length]); + } + } + } + + // 3. Bare JSON — only if the content is essentially nothing-but-JSON, + // so we don't accidentally swallow an explanation paragraph that + // happens to mention a tool. + if (calls.length === 0) { + const m = content.match(LEADING_JSON_RE); + if (m) { + const parsed = _safeParseAny(m[1]); + const normalized = _normalize(parsed, known); + if (normalized.length > 0) { + for (const tc of normalized) calls.push(tc); + consumedRanges.push([0, content.length]); + } + } + } + + if (calls.length === 0) return { patched: false, addedCalls: 0 }; + + // Synthesise OpenAI-style tool_calls. + message.tool_calls = calls.map((c, i) => ({ + id: `call_extracted_${Date.now()}_${i}`, + type: 'function', + function: { + name: c.name, + arguments: typeof c.arguments === 'string' ? c.arguments : JSON.stringify(c.arguments ?? {}), + }, + })); + + // Strip the consumed JSON spans from content. Process in reverse so + // indices stay valid. + let newContent = content; + consumedRanges.sort((a, b) => b[0] - a[0]); + for (const [s, e] of consumedRanges) { + newContent = newContent.slice(0, s) + newContent.slice(e); + } + message.content = newContent.trim(); + + return { patched: true, addedCalls: calls.length }; +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function _safeParseAny(text) { + if (!text) return null; + const trimmed = text.trim(); + // Strip trailing commas which Qwen sometimes emits. + const cleaned = trimmed.replace(/,(\s*[}\]])/g, '$1'); + try { return JSON.parse(cleaned); } catch {} + // Multiple JSON objects newline-separated → wrap into array. + const lines = cleaned.split(/\n+/).map(s => s.trim()).filter(Boolean); + if (lines.length > 1) { + const arr = []; + for (const line of lines) { + try { arr.push(JSON.parse(line.replace(/,(\s*[}\]])/g, '$1'))); } catch { return null; } + } + return arr.length > 0 ? arr : null; + } + return null; +} + +// Normalise whatever the model emitted into [{ name, arguments }, ...]. +// Filters out entries that don't reference a known tool, when we have a +// known-tool list. Without a list, accepts anything name-shaped. +function _normalize(parsed, knownNames) { + if (!parsed) return []; + const items = Array.isArray(parsed) ? parsed : [parsed]; + const out = []; + for (const item of items) { + if (!item || typeof item !== 'object') continue; + const tc = _coerceOne(item); + if (!tc) continue; + if (knownNames.size > 0 && !knownNames.has(tc.name)) continue; + out.push(tc); + } + return out; +} + +function _coerceOne(item) { + // Form A: { name, arguments } + if (typeof item.name === 'string') { + return { name: item.name, arguments: item.arguments ?? item.parameters ?? {} }; + } + // Form B: { function: { name, arguments } } — OpenAI shape inline. + if (item.function && typeof item.function.name === 'string') { + return { name: item.function.name, arguments: item.function.arguments ?? {} }; + } + // Form C: { tool, args } — some custom prompts. + if (typeof item.tool === 'string') { + return { name: item.tool, arguments: item.args ?? item.arguments ?? {} }; + } + return null; +} + +module.exports = { extractFromMessage };