Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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:
- `<tool_call>{...}</tool_call>` (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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions bin/commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand All @@ -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')} <suite> ${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();
Expand Down
12 changes: 12 additions & 0 deletions bin/smallcode.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions src/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 8 additions & 0 deletions src/session/parallel_executor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 || [];
Expand Down
187 changes: 187 additions & 0 deletions src/tools/tool_call_extractor.js
Original file line number Diff line number Diff line change
@@ -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:
// <tool_call>{"name":"shell","arguments":{"cmd":"ls"}}</tool_call>
// 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. <tool_call>{...}</tool_call> ← 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": "<tool_name>", "arguments": <object> }
// or the OpenAI variant:
// { "function": { "name": "<tool_name>", "arguments": "<json string>" } }
// 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 <tool_call>...</tool_call> (qwen / hermes). Multiline + non-greedy.
const TOOL_CALL_TAG_RE = /<tool[_\-]?call>\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 };
Loading