diff --git a/.gitignore b/.gitignore
index d7f38f93..24e72fbd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -80,3 +80,15 @@ marrow.toml
!marrow/features_1_6.marrow
!marrow/quality_monitor.marrow
!marrow/read_guard.marrow
+
+# Kiro IDE (agent workspace, specs, hooks, and scratch)
+.kiro/
+.kiro-tmp/
+
+# Tooling / agent scratch state
+.qartez/
+.compare-modified.json
+
+# Harbor benchmark job output (generated per run, large)
+jobs/
+bench/harbor/
diff --git a/.npmignore b/.npmignore
index 9cc243a1..565a2893 100644
--- a/.npmignore
+++ b/.npmignore
@@ -13,6 +13,15 @@ node_modules/
jobs/
.compare-modified.json
+# Agent/IDE scratch (not part of the published package)
+.kiro/
+.kiro-tmp/
+.qartez/
+.github/
+
+# Tests are not needed by consumers of the package
+test/
+
# Prebuilt binary tarballs (shipped via GitHub releases, not npm)
tmp*/
*.tar.gz
@@ -47,3 +56,7 @@ marrow/
marrow.toml
core.marrow
smallcode.marrow
+
+# TypeScript sources in src/compiled/ — only the compiled .js ships at runtime.
+# build.js generates the .js from these; consumers never need the .ts.
+src/compiled/**/*.ts
diff --git a/.qartez/index.lock b/.qartez/index.lock
deleted file mode 100644
index e69de29b..00000000
diff --git a/.qartez/index.lock.pid b/.qartez/index.lock.pid
deleted file mode 100644
index 7e185f14..00000000
--- a/.qartez/index.lock.pid
+++ /dev/null
@@ -1 +0,0 @@
-16956
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c2177655..55981fba 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,85 @@
# Changelog
+## [1.3.1] - 2026-05-29
+
+### fix: compatibility issues #57, #58, #59
+
+Three reported environment-compatibility bugs:
+
+- **#58 (zsh shell)** — `src/tools/shell_session.js` honoured `$SHELL`
+ when it matched `bash|zsh` but always passed bash-only flags
+ (`--norc --noprofile -i`). zsh rejects `--norc` and exits immediately,
+ so every `bash` tool call failed with "shell exited" for zsh users.
+ Now always launches `bash` on POSIX (the sentinel command-wrapper
+ emits bash/POSIX syntax anyway). `cmd.exe` on Windows unchanged.
+- **#58 (stdin EPIPE crash)** — when the shell died mid-write, the async
+ `'error'` (EPIPE) event on `proc.stdin` had no listener, so Node
+ escalated it to an uncaught exception and crashed the whole process.
+ Added a stdin `'error'` handler that marks the shell dead and lets the
+ next `run()` auto-restart it.
+- **#58 (web tools not advertised)** — `buildSystemPrompt` (both
+ `bin/model_client.js` and `bin/smallcode.js`) enumerated tools in prose
+ but never mentioned `web_search` / `web_fetch`, even with
+ `SMALLCODE_WEB_BROWSE=true`. Small models trusted the prose over the
+ raw `tools` array and refused research tasks. Now conditionally
+ describes the web tools when browsing is enabled.
+- **#59 (remote Ollama)** — the Ollama health check ignored
+ `SMALLCODE_BASE_URL` and always probed `OLLAMA_HOST`/localhost, so a
+ remote Ollama server reported "Ollama not running" with no diagnostics.
+ Now probes the configured base URL (stripping a trailing `/v1`), carries
+ auth, converts URL-embedded basic-auth (`https://user:pass@host`) into a
+ proper `Authorization: Basic` header, and surfaces the real error
+ (ECONNREFUSED / ENOTFOUND / TLS) instead of a generic message.
+- **#57 (prebuild-install deprecation)** — documented in the README that
+ the `prebuild-install@7.1.3: No longer maintained` warning is a harmless
+ upstream deprecation in a transitive optional dependency
+ (`budget-aware-mcp` → `better-sqlite3` → `prebuild-install`) with no
+ newer version to bump to. Added `--omit=optional` guidance.
+
+Also fixed a latent race in `shell_session.js` surfaced while testing:
+late `exit`/`data` events from a superseded shell process could stomp a
+freshly-spawned one. Event handlers are now scoped to their specific
+child process.
+
+Test coverage: `test/shell_session.test.js` (4 cases), `test/web_prompt.test.js`
+(3 cases), and 4 new basic-auth cases in `test/provider_compat.test.js`.
+Full suite: 148 passing.
+
+### fix: Liquid AI tool-call parser (lfm2.x compatibility)
+
+Adds support for Liquid AI's tool-call format. `lfm2.5-8b-a1b-apex` and
+related models emit calls as Python keyword-arg syntax wrapped in
+`<|tool_call_start|>[func(kw='val')]<|tool_call_end|>` markers. LM Studio
+passes this through unchanged because it has no parser for Liquid's
+chat template, so SmallCode previously saw zero tool calls and the model
+appeared completely broken.
+
+- New: `src/tools/liquid_tool_parser.js` — Python-literal-aware parser
+ for the markered format. Handles single/double-quoted strings with
+ `\n`, `\t`, `\\`, `\'`, `\xNN`, `\uNNNN` escapes; numbers, booleans
+ (`True`/`False`/`None`); nested lists and dicts.
+- `src/tools/tool_call_extractor.js` calls the new parser as its
+ highest-priority recovery path. Also falls back to scanning
+ `message.reasoning_content` when `message.content` is empty (Liquid AI
+ splits visible output and chain-of-thought into separate fields).
+- Test coverage: `test/liquid_tool_parser.test.js` (13 cases incl.
+ multi-call lists, JSON-in-content, and reasoning-content fallback).
+
+### bench: polyglot-mini comparison — lfm2.5-8b-a1b-apex vs gemma 4 e4b
+
+Ran the polyglot-mini suite (19 tasks) back-to-back against the LM Studio
+host at `10.0.0.20:1234`. Results in `bench/results/polyglot-mini.md`,
+raw JSON under `.smallcode/benchmarks/`.
+
+- `huihui-gemma-4-e4b-it-abliterated` — **16/19 (84%)**
+- `lfm2.5-8b-a1b-apex` (with parser fix) — **9/19 (47%)**, up from 1/19 (5%)
+
+Remaining lfm2.5 failures are dominated by `finish_reason='length'`
+truncation when the model exhausts its budget on `reasoning_content`
+before producing visible output. Documented as a known limitation in
+`bench/results/polyglot-mini.md`; not addressed in this pass to keep
+the change scoped to the format-parsing fix.
+
## [1.3.0] - 2026-05-26
### feat: plugin system core + provider wizard (#28, #29)
diff --git a/README.md b/README.md
index e6d86006..d7ee5627 100644
--- a/README.md
+++ b/README.md
@@ -64,6 +64,16 @@ SmallCode includes [BoneScript](https://github.com/Doorman11991/BoneScript) and
- **Windows**: Visual Studio Build Tools with "Desktop development with C++" workload, or `npm install -g windows-build-tools`
- **If build fails, SmallCode still works** — it falls back to JSON-based memory automatically
+> **Note on the `prebuild-install@7.1.3: No longer maintained` warning** (issue #57):
+> this is a harmless upstream deprecation notice from a transitive dependency
+> (`budget-aware-mcp` → `better-sqlite3` → `prebuild-install`). `prebuild-install`
+> has no newer published version, and `better-sqlite3` still depends on it, so the
+> warning cannot be silenced by a version bump. It does **not** affect the install —
+> npm warnings are advisory and the package installs normally. If you prefer to skip
+> the native dependency entirely, install without optional deps:
+> `npm install -g smallcode --omit=optional` (you lose FTS5 memory search but keep the
+> JSON memory fallback).
+
### Configuration
Create a `.env` file in your project root:
@@ -182,7 +192,7 @@ Halves the schema context overhead. Model picks a category (read/write/search/ru
Detects repetition loops, patch spirals (stuck on corrupted file → forces rewrite), and greeting regression (model lost context → re-injects task). Saves tokens and time.
### Forgiving Tool Call Parser
-Small models produce messy output. SmallCode parses tool calls from JSON, YAML, XML, Hermes format, or plain text. Auto-repairs common mistakes (wrong param names, type mismatches).
+Small models produce messy output. SmallCode parses tool calls from JSON, YAML, XML, Hermes format, Liquid AI's `<|tool_call_start|>[func(kw='val')]<|tool_call_end|>` markers (lfm2.x), or plain text. Auto-repairs common mistakes (wrong param names, type mismatches). Falls back to scanning `reasoning_content` when `content` is empty (LM Studio reasoning models).
### Patch-First Editing
Search-and-replace as the primary edit primitive. Small models can't reliably reproduce entire files — they truncate, hallucinate, or drift. `patch` is safer and more context-efficient.
diff --git a/bench/results/polyglot-mini.md b/bench/results/polyglot-mini.md
index 5a77ab2d..f9950583 100644
--- a/bench/results/polyglot-mini.md
+++ b/bench/results/polyglot-mini.md
@@ -4,22 +4,108 @@
workspace — no shared state, no internet, no installed toolchain required for
the verify step (all checks are file-content based).
-## Latest result
+## Latest results
-| Model | Pass rate | Mean time/task | Date |
-|---|---|---|---|
-| huihui-gemma-4-e4b-it-abliterated (8B) | **19/19 (100%)** | 11.3s | 2026-05-21 |
+| Model | Pass rate | Mean time/task | Date | Notes |
+|---|---|---|---|---|
+| huihui-gemma-4-e4b-it-abliterated (8B) | **16/19 (84%)** | 24.2s | 2026-05-29 | gemma run |
+| lfm2.5-8b-a1b-apex (8B / A1B MoE) | **9/19 (47%)** | 20.0s | 2026-05-29 | with Liquid AI parser |
+| lfm2.5-8b-a1b-apex (8B / A1B MoE) | 1/19 (5%) | 41.5s | 2026-05-29 | before parser fix |
+| huihui-gemma-4-e4b-it-abliterated (8B) | 19/19 (100%) | 11.3s | 2026-05-21 | original baseline |
-## Per-language breakdown
+The earlier 19/19 gemma run is preserved for historical reference. The
+2026-05-29 re-run was performed back-to-back with the lfm2.5 runs on the
+same LM Studio host so the two columns are directly comparable.
-| Language | Tasks | Passed |
-|---|---|---|
-| Python | 5 | 5 |
-| JavaScript | 4 | 4 |
-| TypeScript | 3 | 3 |
-| Shell | 3 | 3 |
-| Markdown | 2 | 2 |
-| JSON | 2 | 2 |
+## Per-language breakdown — 2026-05-29
+
+| Language | Tasks | gemma 4 e4b | lfm2.5 (post-fix) | lfm2.5 (pre-fix) |
+|--- |--- |--- |--- |--- |
+| Python | 5 | 5/5 | 3/5 | 0/5 |
+| JavaScript | 4 | 3/4 | 1/4 | 1/4 |
+| TypeScript | 3 | 3/3 | 2/3 | 0/3 |
+| Shell | 3 | 2/3 | 0/3 | 0/3 |
+| Markdown | 2 | 2/2 | 2/2 | 0/2 |
+| JSON | 2 | 1/2 | 1/2 | 0/2 |
+
+## Why lfm2.5-8b-a1b-apex was failing — investigation
+
+The original 1/19 result was caused by a **tool-call format mismatch**, not
+model quality. Three issues compound:
+
+### 1. Liquid AI's tool-call template (root cause, fixed)
+
+`lfm2.5-8b-a1b-apex` emits tool calls using Python keyword-arg syntax
+wrapped in markers that LM Studio passes through to `message.content`
+verbatim because it does not have a parser for Liquid's chat template:
+
+```
+<|tool_call_start|>[write_file(path='hello.py', content='def greet(name):\n return f"Hello, {name}!"')]<|tool_call_end|>
+```
+
+SmallCode's existing `tool_call_extractor` only knew about
+`{json}`, fenced-JSON, and bare-leading-JSON
+formats. None matched, so 17 of 19 tasks recorded zero tool calls.
+
+**Fix:** Added `src/tools/liquid_tool_parser.js` — a Python-literal-aware
+parser that handles single/double-quoted strings, escape sequences
+(`\n`, `\t`, `\\`, `\'`), numbers, booleans (`True`/`False`/`None`),
+nested lists, and dicts. Wired into `tool_call_extractor.js` as the
+highest-priority recovery path.
+
+### 2. Empty `content`, populated `reasoning_content` (partially fixed)
+
+LM Studio surfaces lfm2.5's chain-of-thought as a separate
+`reasoning_content` field on the response. When the model's
+`max_tokens` budget is exhausted by reasoning, `content` arrives empty
+even though the model intended to call a tool.
+
+**Fix:** When `message.content` is empty, the extractor now also scans
+`message.reasoning_content` for Liquid-format tool calls. This recovers
+the cases where the call landed in reasoning instead of content.
+
+### 3. `finish_reason='length'` truncation (not yet fixed)
+
+The model frequently spends 200-2400 reasoning tokens on a single turn,
+and the first call in a multi-turn task often returns
+`finish_reason='length'` with empty content. SmallCode's quality monitor
+warns (`empty_response`) but doesn't retry with a higher cap or with
+thinking disabled.
+
+This is the dominant remaining failure mode. About half of the post-fix
+failures show this pattern: the model's first 1-2 calls hit `length`,
+then it produces a successful Liquid call, but by then the agent loop
+has already been derailed (sometimes infinitely loops on `bash` heredoc
+calls that fail on Windows with exit code 9009).
+
+A follow-up fix would catch `finish_reason='length' && content=='' && tool_calls.length===0`
+in the agent loop and either retry with `enable_thinking=false` or with
+a doubled `max_tokens`. Not implemented in this pass to keep the change
+scoped.
+
+## Per-task — 2026-05-29 (post-fix vs gemma)
+
+| Task | gemma 4 e4b | lfm2.5 post-fix |
+|--- |--- |--- |
+| py-fibonacci | ✅ 20.9s · 2t | ✅ 9.1s · 2t |
+| py-class-account | ✅ 205.2s · 22t | ❌ 9.8s · 2t |
+| py-fix-list | ✅ 15.2s · 4t | ❌ 3.9s · 0t |
+| py-add-test | ✅ 8.9s · 3t | ✅ 10.2s · 2t |
+| js-double | ✅ 4.9s · 2t | ✅ 6.5s · 2t |
+| js-arrow | ✅ 13.2s · 3t | ❌ 6.2s · 1t |
+| js-package | ✅ 13.6s · 4t | ❌ 11.4s · 0t |
+| js-fix-async | ❌ 10.3s · 2t | ❌ 167.5s · 0t |
+| ts-interface | ✅ 3.7s · 1t | ✅ 4.6s · 1t |
+| ts-generic | ✅ 8.4s · 2t | ❌ 6.5s · 2t |
+| ts-tsconfig | ✅ 3.9s · 1t | ✅ 4.9s · 1t |
+| sh-list | ✅ 10.3s · 2t | ❌ 78.7s · 0t |
+| sh-makefile | ❌ 49.9s · 12t | ❌ 12.4s · 0t |
+| sh-script | ✅ 52.1s · 10t | ❌ 11.4s · 1t |
+| md-readme | ✅ 11.9s · 2t | ✅ 7.6s · 1t |
+| md-api | ✅ 9.8s · 1t | ✅ 6.6s · 1t |
+| json-config | ✅ 3.7s · 1t | ✅ 5.7s · 1t |
+| json-fix | ❌ 4.2s · 2t | ❌ 7.3s · 1t |
+| multi-imports | ✅ 9.5s · 2t | ✅ 10.1s · 2t |
## Setup
@@ -28,6 +114,9 @@ Requires a running OpenAI-compatible endpoint. Set `SMALLCODE_BASE_URL` and
```bash
npm run bench:polyglot
+# or compare two models directly:
+node bench/harness.js --suite polyglot-mini --model lfm2.5-8b-a1b-apex --base-url http://10.0.0.20:1234/v1
+node bench/harness.js --suite polyglot-mini --model huihui-gemma-4-e4b-it-abliterated --base-url http://10.0.0.20:1234/v1
```
Results are saved to `.smallcode/benchmarks/.json`.
@@ -35,6 +124,11 @@ Results are saved to `.smallcode/benchmarks/.json`.
## Notes
- Run on Windows with LM Studio serving the model locally at `10.0.0.20:1234`
-- Timeout per task: 120s
-- The model used is an 8B parameter model — larger models will generally score
- higher and run faster
+- Timeout per task: 240s
+- lfm2.5-8b-a1b-apex is an A1B MoE — 1B active parameters per token out
+ of an 8B total, served as an apex-quality quant. The 1B active count
+ (not the quantisation) is the architectural constraint here. It splits
+ reasoning and visible output into separate `reasoning_content` and
+ `content` fields, which makes it sensitive to `max_tokens` budgets and
+ to harnesses that don't recognise Liquid AI's Python-kwarg tool-call
+ syntax.
diff --git a/bin/config.js b/bin/config.js
index 6700c770..4038d3d8 100644
--- a/bin/config.js
+++ b/bin/config.js
@@ -321,22 +321,52 @@ async function checkEndpoint(config) {
}
}
- // Ollama endpoint
- const host = process.env.OLLAMA_HOST || 'http://localhost:11434';
+ // Ollama endpoint.
+ // Prefer the user-supplied base URL (SMALLCODE_BASE_URL) over OLLAMA_HOST /
+ // localhost so a remote Ollama server is actually probed. Previously this
+ // branch always hit localhost and reported "Ollama not running" even when
+ // the user pointed SMALLCODE_BASE_URL at a remote host (issue #59).
+ // Ollama's native API lives at the host root (/api/tags), so strip any
+ // trailing OpenAI-style /v1 and normalise the trailing slash.
+ let host = config.model.baseUrl || process.env.OLLAMA_HOST || 'http://localhost:11434';
+ host = host.replace(/\/v1\/?$/, '').replace(/\/+$/, '');
+ const tagsUrl = `${host}/api/tags`;
try {
- const response = await fetch(`${host}/api/tags`);
- if (!response.ok) return false;
+ // Carry auth (e.g. credentials embedded in the URL, or a bearer token /
+ // basic-auth header from buildAuthHeaders) so reverse-proxied Ollama
+ // servers behind auth are reachable.
+ const headers = buildAuthHeaders(config);
+ const response = await fetch(tagsUrl, { headers });
+ if (!response.ok) {
+ console.log(` ⚠ Ollama returned HTTP ${response.status} from ${tagsUrl}`);
+ if (response.status === 401 || response.status === 403) {
+ console.log(` Auth required — include credentials in SMALLCODE_BASE_URL or set OPENAI_API_KEY.`);
+ } else if (response.status === 404) {
+ console.log(` Tip: ${host} has no /api/tags route. Is this actually an Ollama server? For OpenAI-compatible servers set SMALLCODE_PROVIDER=openai.`);
+ } else {
+ console.log(` Check that Ollama is running and reachable at ${host}.`);
+ }
+ return false;
+ }
const data = await response.json();
const models = data.models || [];
const hasModel = models.some(m => m.name.includes(config.model.name.split(':')[0]));
if (!hasModel) {
- console.log(` ⚠ Model "${config.model.name}" not found in Ollama.`);
+ console.log(` ⚠ Model "${config.model.name}" not found on the Ollama server at ${host}.`);
console.log(` Run: ollama pull ${config.model.name}`);
return false;
}
return true;
- } catch {
- console.log(' ⚠ Ollama not running. Start it with: ollama serve');
+ } catch (e) {
+ // Surface the underlying error instead of assuming Ollama is simply not
+ // started — the user may have a wrong URL, DNS failure, or TLS issue.
+ const msg = e && e.message ? e.message : String(e);
+ console.log(` ⚠ Cannot reach Ollama at ${tagsUrl}: ${msg}`);
+ const hint = /ECONNREFUSED/.test(msg) ? ' If this is a local server, start it with: ollama serve' :
+ /ENOTFOUND|EAI_AGAIN/.test(msg) ? ' Check the hostname in SMALLCODE_BASE_URL.' :
+ /certificate|TLS|SSL/i.test(msg) ? ' TLS error — check the https certificate or use http.' :
+ ' Check that SMALLCODE_BASE_URL points at a running Ollama server.';
+ console.log(hint);
return false;
}
}
@@ -375,6 +405,19 @@ function buildAuthHeaders(config) {
if (apiKey) {
headers['Authorization'] = `Bearer ${apiKey}`;
}
+ // Basic auth embedded in the URL (https://user:pass@host). The fetch() API
+ // does NOT apply URL userinfo automatically, so a reverse-proxied server
+ // behind basic auth would 401. Convert it to an explicit header. An
+ // explicit API key (Bearer, above) takes precedence if both are present.
+ if (!apiKey && modelConfig.baseUrl) {
+ try {
+ const u = new URL(modelConfig.baseUrl);
+ if (u.username) {
+ const creds = Buffer.from(`${decodeURIComponent(u.username)}:${decodeURIComponent(u.password)}`).toString('base64');
+ headers['Authorization'] = `Basic ${creds}`;
+ }
+ } catch { /* not a parseable URL — ignore */ }
+ }
if (baseUrl.includes('openrouter.ai')) {
headers['HTTP-Referer'] = 'https://github.com/Doorman11991/smallcode';
headers['X-Title'] = 'SmallCode';
diff --git a/bin/model_client.js b/bin/model_client.js
index 179bee76..834b567e 100644
--- a/bin/model_client.js
+++ b/bin/model_client.js
@@ -274,6 +274,19 @@ Rules:
prompt += `\n\nBONESCRIPT MODE — For Node.js/TypeScript backends, use BoneScript.`;
}
+ // Web research tools — only advertise them when web browsing is enabled.
+ // Smaller models trust the prose tool description over the raw `tools`
+ // array and will refuse research tasks ("my tools are for code files...")
+ // unless the prompt explicitly says the web tools exist (issue #58).
+ if (String(process.env.SMALLCODE_WEB_BROWSE).toLowerCase() === 'true') {
+ prompt += `
+
+WEB RESEARCH — you have live internet access:
+- web_search: focused query -> relevant results with URLs.
+- web_fetch: one URL -> that page's readable text.
+Use these whenever asked to research, look up, or find current information. Report a short summary with source URL(s); do NOT write scripts to fetch the web.`;
+ }
+
prompt += `\nWorking directory: ${process.cwd()}`;
prompt += memCtx + skillCtx + pluginCtx;
return prompt;
diff --git a/bin/smallcode.js b/bin/smallcode.js
index 91b688a0..4f8d13cb 100755
--- a/bin/smallcode.js
+++ b/bin/smallcode.js
@@ -1934,6 +1934,14 @@ CRITICAL — large file rule: write_file calls are limited to 60 lines / ~8KB. l
prompt += `\n\nFor Node.js backends: write a .bone file → bone_check → bone_compile. Don't hand-write routes.`;
}
+ // Web research tools — only advertise when web browsing is enabled. Small
+ // models trust this prose over the raw `tools` array and otherwise refuse
+ // research tasks ("my tools are for code files only") even though the web
+ // tools are available (issue #58).
+ if (taskType !== 'explanation' && String(process.env.SMALLCODE_WEB_BROWSE).toLowerCase() === 'true') {
+ prompt += `\n\nWEB RESEARCH — you have live internet access: web_search (query → results with URLs) and web_fetch (URL → page text). Use them for research / look-up / current-info tasks; report a short summary with source URL(s). Do NOT write scripts to fetch the web.`;
+ }
+
if (cacheSplit) {
// Dynamic context goes into a separate [CONTEXT] user message — see
// buildDynamicContext(). Plan + plugins stay here (system role = authoritative).
@@ -2409,6 +2417,52 @@ async function chatCompletion(config, messages) {
const data = await response.json();
+ // Length-truncation recovery: reasoning models served via LM Studio
+ // (lfm2.x, Qwen3, DeepSeek R1) expose a separate `reasoning_content`
+ // field and can burn the entire `max_tokens` budget on thinking, then
+ // return an empty `content` + empty `tool_calls` with
+ // finish_reason='length'. Feeding that empty turn back to the loop
+ // triggers a spurious empty_response correction spiral. Instead, retry
+ // ONCE with a doubled output budget so the model can finish emitting
+ // its visible answer / tool call. Bounded retry — only fires when the
+ // turn is genuinely empty (no usable output) and was length-capped.
+ try {
+ const _choice = data?.choices?.[0];
+ const _msg = _choice?.message;
+ const _finish = _choice?.finish_reason;
+ const _emptyContent = !(typeof _msg?.content === 'string' && _msg.content.trim());
+ const _noToolCalls = !(Array.isArray(_msg?.tool_calls) && _msg.tool_calls.length > 0);
+ const _hadReasoning = typeof _msg?.reasoning_content === 'string' && _msg.reasoning_content.length > 0;
+ const _retryDisabled = String(process.env.SMALLCODE_LENGTH_RETRY || 'true').toLowerCase() === 'false';
+ if (!_retryDisabled && _finish === 'length' && _emptyContent && _noToolCalls && _hadReasoning && !body.__lengthRetry) {
+ const _curMax = body.max_tokens || 8192;
+ const _cap = parseInt(process.env.SMALLCODE_MAX_OUTPUT_TOKENS_CAP) || 16384;
+ const retryBody = { ...body, max_tokens: Math.min(_curMax * 2, _cap), __lengthRetry: true };
+ if (_fullscreenRef) _fullscreenRef.addTool('retry', 'warn', `length-capped, retrying @ ${retryBody.max_tokens}t`);
+ else console.log(` \x1b[33m⚠ response truncated (all budget spent on reasoning) — retrying at ${retryBody.max_tokens} tokens\x1b[0m`);
+ const retryResp = await fetch(`${baseUrl}/chat/completions`, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify(retryBody),
+ });
+ if (retryResp.ok) {
+ const retryData = await retryResp.json();
+ const rChoice = retryData?.choices?.[0];
+ const rMsg = rChoice?.message;
+ const rHasOutput = (typeof rMsg?.content === 'string' && rMsg.content.trim()) ||
+ (Array.isArray(rMsg?.tool_calls) && rMsg.tool_calls.length > 0);
+ // Only adopt the retry if it actually produced usable output.
+ if (rHasOutput) {
+ if (retryData.usage) {
+ tokenMonitor.recordCall(retryData.usage.prompt_tokens, retryData.usage.completion_tokens);
+ traceRecorder.recordTokens(retryData.usage.prompt_tokens, retryData.usage.completion_tokens);
+ }
+ return retryData;
+ }
+ }
+ }
+ } catch {}
+
// Plugin hook: post_request
if (pluginLoader) {
await pluginLoader.runHooks('post_request', {
diff --git a/package.json b/package.json
index d9ca38d9..1a698a02 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "smallcode",
- "version": "1.3.0",
+ "version": "1.3.1",
"description": "AI coding agent optimized for small LLMs (8B-35B parameters)",
"main": "src/api/index.js",
"bin": {
diff --git a/src/model/thinking_budget.js b/src/model/thinking_budget.js
index 66bf0b3d..e868b5b1 100644
--- a/src/model/thinking_budget.js
+++ b/src/model/thinking_budget.js
@@ -92,6 +92,12 @@ function applyThinkingBudget(body, options = {}) {
// OpenRouter (which proxies to OpenAI). Other providers reject it with 400.
// GPT-5.5, gpt-4o, and most local models do NOT support it.
const modelName = String(body.model || '').toLowerCase();
+ // lfm2 (Liquid AI) emits a `reasoning_content` field, but injecting
+ // `chat_template_kwargs.enable_thinking` into the request *suppresses*
+ // its tool-call output entirely on LM Studio (observed on
+ // lfm2.5-8b-a1b-apex). Treat it as a non-budget-controllable reasoning
+ // model: the reasoning fallback in tool_call_extractor handles its
+ // empty-content edge case, no need to override the template.
const isReasoningModel = /(^|[\/\-_])(o1|o3|o4|qwen3|qwq|deepseek-r|deepseek-v3-reason|claude-3-7|claude-4)/.test(modelName);
if (isReasoningModel && isOpenAICloud) {
if (!opts.disable) {
diff --git a/src/tools/liquid_tool_parser.js b/src/tools/liquid_tool_parser.js
new file mode 100644
index 00000000..79e9ad3b
--- /dev/null
+++ b/src/tools/liquid_tool_parser.js
@@ -0,0 +1,314 @@
+// SmallCode — Liquid AI tool-call parser
+//
+// Liquid AI's LFM2 family (e.g. `lfm2.5-8b-a1b-apex`) emits tool calls using
+// a Python-keyword-arg syntax wrapped in `<|tool_call_start|>...<|tool_call_end|>`
+// markers. LM Studio passes this through to `message.content` unchanged
+// because it does not have a parser configured for Liquid's chat template.
+//
+// Example raw output observed from `lfm2.5-8b-a1b-apex` via LM Studio:
+//
+// <|tool_call_start|>[write_file(path='hello.py', content='def greet(name):\n return f"Hello, {name}!"')]<|tool_call_end|>
+//
+// And for multi-call turns:
+//
+// <|tool_call_start|>[read_file(path='a.py'), bash(command='ls')]<|tool_call_end|>
+//
+// This module recognises that template and converts it into the
+// OpenAI-style `tool_calls` shape that the rest of the agent loop expects.
+//
+// Recognised value types inside a kwarg:
+// • Python string literal — single- or double-quoted, with `\n`, `\t`,
+// `\r`, `\\`, `\'`, `\"`, `\xNN`, `\uNNNN` escape sequences
+// • Number (int or float, optional sign)
+// • Boolean (`True`/`False`) and `None`
+// • List `[...]` and dict `{...}` containing the above (best-effort)
+//
+// Conservative on failure: if any kwarg can't be parsed, we drop the
+// whole call rather than guess. The fallback behaviour is the same as
+// having no extractor — the bare text is shown to the user.
+
+'use strict';
+
+// Match `<|tool_call_start|>...<|tool_call_end|>` (multiline, non-greedy).
+// Tolerant to either pipe placement (`<|...|>` is the documented form,
+// but some quants emit `` without pipes — accept both).
+const LIQUID_BLOCK_RE = /<\|?tool[_\-]?call[_\-]?start\|?>\s*([\s\S]*?)\s*<\|?tool[_\-]?call[_\-]?end\|?>/gi;
+
+/**
+ * @param {string} content Text from `message.content`
+ * @returns {{ calls: Array<{name:string, arguments:object}>, ranges: Array<[number,number]> }}
+ * `calls` — extracted, in document order
+ * `ranges` — [start, end) spans in the original content that should be
+ * stripped after a successful extraction
+ */
+function parseLiquidToolCalls(content) {
+ if (typeof content !== 'string' || !content.includes('tool_call')) {
+ return { calls: [], ranges: [] };
+ }
+
+ const calls = [];
+ const ranges = [];
+
+ for (const match of content.matchAll(LIQUID_BLOCK_RE)) {
+ const inner = match[1].trim();
+ const parsed = _parseCallList(inner);
+ if (parsed.length > 0) {
+ for (const c of parsed) calls.push(c);
+ ranges.push([match.index, match.index + match[0].length]);
+ }
+ }
+
+ return { calls, ranges };
+}
+
+// ── Internal: parse `[func1(...), func2(...)]` or bare `func(...)` ──────────
+
+function _parseCallList(text) {
+ let s = text.trim();
+ if (!s) return [];
+ // Strip outer brackets if the whole thing is a list (the documented form).
+ if (s.startsWith('[') && s.endsWith(']')) {
+ s = s.slice(1, -1).trim();
+ }
+ if (!s) return [];
+
+ const calls = [];
+ let pos = 0;
+ while (pos < s.length) {
+ // Skip whitespace and optional separating commas between calls.
+ while (pos < s.length && /[\s,]/.test(s[pos])) pos++;
+ if (pos >= s.length) break;
+
+ const callStart = pos;
+ // Grab the function name — identifier chars only.
+ const nameMatch = s.slice(pos).match(/^([A-Za-z_][A-Za-z0-9_]*)\s*\(/);
+ if (!nameMatch) return [];
+ const name = nameMatch[1];
+ pos += nameMatch[0].length; // now positioned just inside `(`
+
+ // Walk forward until the matching `)`, respecting nested brackets and
+ // quoted strings.
+ const argsEnd = _findMatchingParen(s, pos);
+ if (argsEnd === -1) return [];
+ const argsText = s.slice(pos, argsEnd);
+ pos = argsEnd + 1;
+
+ const args = _parseKwargs(argsText);
+ if (args == null) return []; // bail out on malformed args
+ calls.push({ name, arguments: args });
+
+ // Sanity: prevent infinite loop if regex/pos somehow didn't advance.
+ if (pos <= callStart) return calls;
+ }
+ return calls;
+}
+
+// Walk from `start` forward, return index of the matching `)`. -1 if not found.
+function _findMatchingParen(s, start) {
+ let depth = 1;
+ let i = start;
+ while (i < s.length) {
+ const c = s[i];
+ if (c === "'" || c === '"') {
+ i = _skipString(s, i);
+ if (i === -1) return -1;
+ continue;
+ }
+ if (c === '(' || c === '[' || c === '{') depth++;
+ else if (c === ')' || c === ']' || c === '}') {
+ depth--;
+ if (depth === 0 && c === ')') return i;
+ }
+ i++;
+ }
+ return -1;
+}
+
+// Skip a Python-style string starting at `s[start]` (single or double quote).
+// Returns index just past the closing quote, or -1 on EOF.
+function _skipString(s, start) {
+ const quote = s[start];
+ let i = start + 1;
+ while (i < s.length) {
+ const c = s[i];
+ if (c === '\\') { i += 2; continue; }
+ if (c === quote) return i + 1;
+ i++;
+ }
+ return -1;
+}
+
+// Parse `key=value, key=value, ...` (Python kwargs). Returns plain object,
+// or null on parse failure.
+function _parseKwargs(text) {
+ const out = {};
+ let pos = 0;
+ while (pos < text.length) {
+ while (pos < text.length && /\s/.test(text[pos])) pos++;
+ if (pos >= text.length) break;
+
+ const keyMatch = text.slice(pos).match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*/);
+ if (!keyMatch) return null;
+ const key = keyMatch[1];
+ pos += keyMatch[0].length;
+
+ const v = _parseValue(text, pos);
+ if (!v) return null;
+ out[key] = v.value;
+ pos = v.next;
+
+ while (pos < text.length && /\s/.test(text[pos])) pos++;
+ if (pos < text.length && text[pos] === ',') pos++;
+ }
+ return out;
+}
+
+// Parse a single Python literal starting at `text[pos]`. Returns
+// `{ value, next }` or null on failure.
+function _parseValue(text, pos) {
+ while (pos < text.length && /\s/.test(text[pos])) pos++;
+ if (pos >= text.length) return null;
+ const c = text[pos];
+
+ if (c === "'" || c === '"') return _parseString(text, pos);
+ if (c === '[') return _parseList(text, pos);
+ if (c === '{') return _parseDict(text, pos);
+
+ // Identifier-like: True/False/None or a bare keyword we don't accept.
+ const idMatch = text.slice(pos).match(/^(True|False|None)\b/);
+ if (idMatch) {
+ const map = { True: true, False: false, None: null };
+ return { value: map[idMatch[1]], next: pos + idMatch[0].length };
+ }
+
+ // Number — including scientific notation and signs.
+ const numMatch = text.slice(pos).match(/^[+-]?(\d+\.\d*|\.\d+|\d+)([eE][+-]?\d+)?/);
+ if (numMatch) {
+ return { value: parseFloat(numMatch[0]), next: pos + numMatch[0].length };
+ }
+
+ return null;
+}
+
+function _parseString(text, pos) {
+ const quote = text[pos];
+ let i = pos + 1;
+ let out = '';
+ while (i < text.length) {
+ const c = text[i];
+ if (c === '\\') {
+ const next = text[i + 1];
+ switch (next) {
+ case 'n': out += '\n'; i += 2; break;
+ case 't': out += '\t'; i += 2; break;
+ case 'r': out += '\r'; i += 2; break;
+ case '\\': out += '\\'; i += 2; break;
+ case "'": out += "'"; i += 2; break;
+ case '"': out += '"'; i += 2; break;
+ case '0': out += '\0'; i += 2; break;
+ case 'a': out += '\x07'; i += 2; break;
+ case 'b': out += '\b'; i += 2; break;
+ case 'f': out += '\f'; i += 2; break;
+ case 'v': out += '\v'; i += 2; break;
+ case 'x': {
+ const hex = text.slice(i + 2, i + 4);
+ if (/^[0-9a-fA-F]{2}$/.test(hex)) {
+ out += String.fromCharCode(parseInt(hex, 16));
+ i += 4;
+ } else { out += next; i += 2; }
+ break;
+ }
+ case 'u': {
+ const hex = text.slice(i + 2, i + 6);
+ if (/^[0-9a-fA-F]{4}$/.test(hex)) {
+ out += String.fromCharCode(parseInt(hex, 16));
+ i += 6;
+ } else { out += next; i += 2; }
+ break;
+ }
+ default:
+ // Unknown escape — preserve both chars (Python's behaviour for
+ // non-recognised escapes is to keep them literal).
+ out += '\\' + (next || '');
+ i += 2;
+ }
+ continue;
+ }
+ if (c === quote) {
+ return { value: out, next: i + 1 };
+ }
+ out += c;
+ i++;
+ }
+ return null; // unterminated
+}
+
+function _parseList(text, pos) {
+ // Find matching ]
+ let depth = 1;
+ let i = pos + 1;
+ while (i < text.length && depth > 0) {
+ const c = text[i];
+ if (c === "'" || c === '"') { i = _skipString(text, i); if (i === -1) return null; continue; }
+ if (c === '[' || c === '(' || c === '{') depth++;
+ else if (c === ']' || c === ')' || c === '}') {
+ depth--;
+ if (depth === 0 && c === ']') break;
+ }
+ i++;
+ }
+ if (depth !== 0) return null;
+ const inner = text.slice(pos + 1, i);
+ const arr = [];
+ let p = 0;
+ while (p < inner.length) {
+ while (p < inner.length && /\s/.test(inner[p])) p++;
+ if (p >= inner.length) break;
+ const v = _parseValue(inner, p);
+ if (!v) return null;
+ arr.push(v.value);
+ p = v.next;
+ while (p < inner.length && /\s/.test(inner[p])) p++;
+ if (p < inner.length && inner[p] === ',') p++;
+ }
+ return { value: arr, next: i + 1 };
+}
+
+function _parseDict(text, pos) {
+ // Find matching }
+ let depth = 1;
+ let i = pos + 1;
+ while (i < text.length && depth > 0) {
+ const c = text[i];
+ if (c === "'" || c === '"') { i = _skipString(text, i); if (i === -1) return null; continue; }
+ if (c === '[' || c === '(' || c === '{') depth++;
+ else if (c === ']' || c === ')' || c === '}') {
+ depth--;
+ if (depth === 0 && c === '}') break;
+ }
+ i++;
+ }
+ if (depth !== 0) return null;
+ const inner = text.slice(pos + 1, i);
+ const obj = {};
+ let p = 0;
+ while (p < inner.length) {
+ while (p < inner.length && /\s/.test(inner[p])) p++;
+ if (p >= inner.length) break;
+ const k = _parseValue(inner, p);
+ if (!k || (typeof k.value !== 'string' && typeof k.value !== 'number')) return null;
+ p = k.next;
+ while (p < inner.length && /\s/.test(inner[p])) p++;
+ if (inner[p] !== ':') return null;
+ p++;
+ const v = _parseValue(inner, p);
+ if (!v) return null;
+ obj[String(k.value)] = v.value;
+ p = v.next;
+ while (p < inner.length && /\s/.test(inner[p])) p++;
+ if (p < inner.length && inner[p] === ',') p++;
+ }
+ return { value: obj, next: i + 1 };
+}
+
+module.exports = { parseLiquidToolCalls };
diff --git a/src/tools/shell_session.js b/src/tools/shell_session.js
index 8ae7d3fa..ac6a3c6f 100644
--- a/src/tools/shell_session.js
+++ b/src/tools/shell_session.js
@@ -56,10 +56,19 @@ class ShellSession {
if (this.starting) return this.starting;
this.starting = (async () => {
+ // Fresh spawn — clear any leftover buffer + mark live before we attach
+ // handlers, so a restart after stop() starts from a clean slate.
+ this.buffer = '';
+ this._dead = false;
const isWin = process.platform === 'win32';
- // Use bash on POSIX (more predictable than sh), cmd.exe on Windows.
- // We could prefer pwsh on Windows but cmd.exe is universally available.
- const shellCmd = isWin ? 'cmd.exe' : (process.env.SHELL && /bash|zsh/.test(process.env.SHELL) ? process.env.SHELL : 'bash');
+ // POSIX: always use bash, never $SHELL. The sentinel command-wrapper
+ // below emits bash/POSIX syntax (`printf '\n%s_%d_\n' $?`) and passes
+ // bash-only flags (--norc --noprofile -i). Honouring $SHELL broke every
+ // zsh user: `zsh --norc ...` errors with "zsh: no such option: norc"
+ // and the shell exits immediately, so every bash tool call failed with
+ // "shell exited" (issue #58). cmd.exe on Windows.
+ const shellCmd = isWin ? 'cmd.exe' : 'bash';
+
const shellArgs = isWin ? ['/Q', '/K', 'echo off & prompt $G'] : ['--norc', '--noprofile', '-i'];
try {
@@ -78,11 +87,30 @@ class ShellSession {
return false;
}
- this.proc.on('error', () => { this._dead = true; });
- this.proc.on('exit', () => { this._dead = true; this._failPending('shell exited'); });
-
- // Demux output via sentinel matching
+ // Capture the specific child so a late event from a PREVIOUS shell can't
+ // stomp a freshly-spawned one. Without this guard, `stop()` immediately
+ // followed by `run()` races: the old proc's async 'exit' fires after the
+ // new shell spawned and flips this._dead=true on the live session,
+ // killing it (and producing empty output).
+ const proc = this.proc;
+ const isCurrent = () => this.proc === proc;
+
+ proc.on('error', () => { if (isCurrent()) this._dead = true; });
+ proc.on('exit', () => { if (isCurrent()) { this._dead = true; this._failPending('shell exited'); } });
+
+ // Guard against async EPIPE on stdin. When the shell dies mid-write,
+ // `proc.stdin` emits an async 'error' (EPIPE) event. With no listener,
+ // Node escalates it to an uncaught exception and crashes the whole
+ // process — the try/catch around stdin.write() only catches the
+ // synchronous throw, not the async event. Mark the shell dead and let
+ // the next run() auto-restart it (issue #58).
+ proc.stdin.on('error', () => { if (isCurrent()) { this._dead = true; this._failPending('shell stdin error'); } });
+
+ // Demux output via sentinel matching. Ignore late chunks from a
+ // superseded process so they can't corrupt a freshly-spawned shell's
+ // buffer (same race as the exit-handler guard above).
const onChunk = (chunk) => {
+ if (!isCurrent()) return;
this.buffer += chunk.toString('utf8');
// Hard cap to prevent runaway commands from OOMing us.
// Be careful not to slice mid-sentinel, otherwise the head command
@@ -109,8 +137,8 @@ class ShellSession {
}
this._drain();
};
- this.proc.stdout.on('data', onChunk);
- this.proc.stderr.on('data', onChunk);
+ proc.stdout.on('data', onChunk);
+ proc.stderr.on('data', onChunk);
return true;
})();
diff --git a/src/tools/tool_call_extractor.js b/src/tools/tool_call_extractor.js
index 88c75f08..1e032dbb 100644
--- a/src/tools/tool_call_extractor.js
+++ b/src/tools/tool_call_extractor.js
@@ -20,10 +20,11 @@
// 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
+// 1. {...} ← Hermes / qwen2.5 native
+// 2. <|tool_call_start|>[func(kw=val)]<|tool_call_end|> ← Liquid AI lfm2.x
+// 3. ```json ... ``` (fenced code block) ← qwen-coder / generic
+// 4. ```tool_call ... ``` ← some llama3 fine-tunes
+// 5. Bare JSON object at the start of content
//
// All formats expect the JSON to be of shape:
// { "name": "", "arguments":