From ae21383c50d0b88af489bb36ab90cfdd48e21d08 Mon Sep 17 00:00:00 2001 From: DanTheMan181 <283874042+Doorman11991@users.noreply.github.com> Date: Fri, 29 May 2026 01:04:25 -0700 Subject: [PATCH] fix: compat issues #57/#58/#59 + Liquid AI tool-call parser (v1.3.1) #58 zsh shell: always launch bash on POSIX (was honouring $SHELL=zsh while passing bash-only flags --norc, so every bash tool call died with "shell exited"). Also scope shell event handlers to their specific child process to fix a latent race where a superseded shell's late exit/data events stomped a freshly-spawned one. #58 stdin EPIPE: attach a stdin 'error' handler so a dead shell fails the single tool call instead of crashing the whole process. #58 web tools: advertise web_search/web_fetch in the system prompt when SMALLCODE_WEB_BROWSE=true (both prompt builders) so small models stop refusing research tasks. #59 remote Ollama: probe SMALLCODE_BASE_URL (not just OLLAMA_HOST/localhost) for the health check, carry auth, convert URL-embedded basic-auth (https://user:pass@host) into an Authorization: Basic header, and surface the real connection error instead of a generic "not running" message. #57 prebuild-install: documented the harmless upstream deprecation warning in the README (transitive optional dep; no newer version to bump to). Also lands Liquid AI lfm2.x tool-call support: a Python-kwarg parser for the <|tool_call_start|>[func(kw='val')]<|tool_call_end|> format LM Studio passes through verbatim, plus a reasoning_content fallback for empty content. Packaging: bump to 1.3.1; .npmignore now excludes .qartez/.github/test and redundant src/compiled/*.ts sources (173->134 files, 1.4MB->1.1MB unpacked). .gitignore adds .kiro/, .kiro-tmp/, .qartez/, jobs/, bench/harbor/. Tests: +18 cases. Full suite 148 passing. --- .gitignore | 12 ++ .npmignore | 13 ++ .qartez/index.lock | 0 .qartez/index.lock.pid | 1 - CHANGELOG.md | 80 ++++++++ README.md | 12 +- bench/results/polyglot-mini.md | 126 +++++++++++-- bin/config.js | 57 +++++- bin/model_client.js | 13 ++ bin/smallcode.js | 54 ++++++ package.json | 2 +- src/model/thinking_budget.js | 6 + src/tools/liquid_tool_parser.js | 314 +++++++++++++++++++++++++++++++ src/tools/shell_session.js | 46 ++++- src/tools/tool_call_extractor.js | 55 ++++-- test/liquid_tool_parser.test.js | 144 ++++++++++++++ test/provider_compat.test.js | 58 ++++++ test/shell_session.test.js | 70 +++++++ test/web_prompt.test.js | 62 ++++++ 19 files changed, 1078 insertions(+), 47 deletions(-) delete mode 100644 .qartez/index.lock delete mode 100644 .qartez/index.lock.pid create mode 100644 src/tools/liquid_tool_parser.js create mode 100644 test/liquid_tool_parser.test.js create mode 100644 test/shell_session.test.js create mode 100644 test/web_prompt.test.js 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": } @@ -59,8 +60,16 @@ function extractFromMessage(message, toolSchemas) { if (Array.isArray(message.tool_calls) && message.tool_calls.length > 0) { return { patched: false, addedCalls: 0 }; } - const content = typeof message.content === 'string' ? message.content : ''; + // Some local providers (LM Studio with Liquid AI lfm2.x, llama.cpp with + // Qwen3 reasoning) split the response: visible text goes into `content` + // and chain-of-thought goes into `reasoning_content`. When the budget is + // tight the model can emit its tool call in reasoning_content and leave + // content empty. Fall back to scanning reasoning_content if content is empty. + const primary = typeof message.content === 'string' ? message.content : ''; + const fallback = typeof message.reasoning_content === 'string' ? message.reasoning_content : ''; + const content = primary && primary.trim().length > 0 ? primary : fallback; if (!content) return { patched: false, addedCalls: 0 }; + const usingReasoningFallback = content === fallback && content !== primary; const known = new Set(); if (Array.isArray(toolSchemas)) { @@ -73,7 +82,22 @@ function extractFromMessage(message, toolSchemas) { const calls = []; const consumedRanges = []; // [start, end) of content we transferred into tool_calls - // 1. Tagged tool calls — strongest signal. + // 0. Liquid AI tool_call markers — `<|tool_call_start|>[func(kw=val)]<|tool_call_end|>`. + // Strongest signal when present; processed first so the rest of the + // pipeline doesn't try to interpret the Python-syntax payload as JSON. + try { + const { parseLiquidToolCalls } = require('./liquid_tool_parser'); + const { calls: liquidCalls, ranges: liquidRanges } = parseLiquidToolCalls(content); + for (const c of liquidCalls) { + if (known.size > 0 && !known.has(c.name)) continue; + calls.push(c); + } + if (liquidCalls.length > 0) { + for (const r of liquidRanges) consumedRanges.push(r); + } + } catch {} + + // 1. Tagged tool calls — strongest JSON-shaped 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); @@ -120,13 +144,20 @@ function extractFromMessage(message, toolSchemas) { })); // 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); + // indices stay valid. Only mutate `message.content` — leave + // `reasoning_content` untouched (it's metadata, not chat history). + if (!usingReasoningFallback) { + 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(); + } else { + // We extracted from reasoning_content; ensure message.content is at + // least an empty string so the agent loop sees a valid message. + message.content = typeof message.content === 'string' ? message.content : ''; } - message.content = newContent.trim(); return { patched: true, addedCalls: calls.length }; } diff --git a/test/liquid_tool_parser.test.js b/test/liquid_tool_parser.test.js new file mode 100644 index 00000000..c29fe583 --- /dev/null +++ b/test/liquid_tool_parser.test.js @@ -0,0 +1,144 @@ +// SmallCode — Liquid AI tool-call parser tests +// +// Coverage is the formats observed from `lfm2.5-8b-a1b-apex` via LM Studio +// (issue: bench polyglot-mini scoring 1/19 because every call landed in +// `message.content` instead of `tool_calls`). + +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert'); + +const { parseLiquidToolCalls } = require('../src/tools/liquid_tool_parser'); +const { extractFromMessage } = require('../src/tools/tool_call_extractor'); + +const SCHEMA = [ + { function: { name: 'read_file' } }, + { function: { name: 'write_file' } }, + { function: { name: 'patch' } }, + { function: { name: 'bash' } }, +]; + +test('parses a single write_file call', () => { + const text = `<|tool_call_start|>[write_file(path='hello.py', content='def greet(name):\\n return f"Hello, {name}!"')]<|tool_call_end|>`; + const { calls, ranges } = parseLiquidToolCalls(text); + assert.equal(calls.length, 1); + assert.equal(calls[0].name, 'write_file'); + assert.equal(calls[0].arguments.path, 'hello.py'); + assert.match(calls[0].arguments.content, /^def greet\(name\):\n {4}return f"Hello, \{name\}!"/); + assert.equal(ranges.length, 1); + assert.equal(ranges[0][0], 0); +}); + +test('parses bash() with single-quoted command', () => { + const text = `<|tool_call_start|>[bash(command='ls -la')]<|tool_call_end|>`; + const { calls } = parseLiquidToolCalls(text); + assert.deepEqual(calls, [{ name: 'bash', arguments: { command: 'ls -la' } }]); +}); + +test('parses multi-call list', () => { + const text = `<|tool_call_start|>[read_file(path='a.py'), bash(command='ls')]<|tool_call_end|>`; + const { calls } = parseLiquidToolCalls(text); + assert.equal(calls.length, 2); + assert.equal(calls[0].name, 'read_file'); + assert.equal(calls[1].name, 'bash'); +}); + +test('handles tab + newline escapes for Makefile content', () => { + const text = `<|tool_call_start|>[write_file(path='Makefile', content='build:\\n\\t@echo building\\n')]<|tool_call_end|>`; + const { calls } = parseLiquidToolCalls(text); + assert.equal(calls[0].arguments.content, 'build:\n\t@echo building\n'); +}); + +test('handles JSON content with embedded double quotes', () => { + const text = `<|tool_call_start|>[write_file(path='config.json', content='{"name": "myapp", "port": 3000, "features": ["auth", "logging"]}')]<|tool_call_end|>`; + const { calls } = parseLiquidToolCalls(text); + assert.equal(calls[0].arguments.path, 'config.json'); + const parsed = JSON.parse(calls[0].arguments.content); + assert.equal(parsed.name, 'myapp'); + assert.equal(parsed.port, 3000); + assert.deepEqual(parsed.features, ['auth', 'logging']); +}); + +test('handles mixed numeric and string args', () => { + const text = `<|tool_call_start|>[some_tool(count=3, label='x', ratio=1.5, on=True, off=None)]<|tool_call_end|>`; + const { calls } = parseLiquidToolCalls(text); + assert.deepEqual(calls[0].arguments, { count: 3, label: 'x', ratio: 1.5, on: true, off: null }); +}); + +test('returns no calls when no markers present', () => { + assert.deepEqual(parseLiquidToolCalls('hello world').calls, []); + assert.deepEqual(parseLiquidToolCalls('').calls, []); +}); + +test('extractor patches message in-place from Liquid format', () => { + const message = { + role: 'assistant', + content: `\n<|tool_call_start|>[write_file(path='hello.py', content='print("hi")')]<|tool_call_end|>\n`, + }; + const { patched, addedCalls } = extractFromMessage(message, SCHEMA); + assert.equal(patched, true); + assert.equal(addedCalls, 1); + assert.equal(message.tool_calls.length, 1); + assert.equal(message.tool_calls[0].function.name, 'write_file'); + const args = JSON.parse(message.tool_calls[0].function.arguments); + assert.equal(args.path, 'hello.py'); + assert.equal(args.content, 'print("hi")'); + // The Liquid block is consumed from content. + assert.equal(message.content.includes('tool_call_start'), false); +}); + +test('extractor leaves message alone when structured tool_calls already present', () => { + const message = { + role: 'assistant', + content: `<|tool_call_start|>[bash(command='ls')]<|tool_call_end|>`, + tool_calls: [{ id: '1', type: 'function', function: { name: 'bash', arguments: '{}' } }], + }; + const r = extractFromMessage(message, SCHEMA); + assert.equal(r.patched, false); +}); + +test('extractor filters out unknown tool names', () => { + const message = { + role: 'assistant', + content: `<|tool_call_start|>[no_such_tool(x='y')]<|tool_call_end|>`, + }; + const r = extractFromMessage(message, SCHEMA); + assert.equal(r.patched, false); +}); + +test('parser rejects malformed payload conservatively', () => { + const text = `<|tool_call_start|>[write_file(path=]<|tool_call_end|>`; + const { calls } = parseLiquidToolCalls(text); + assert.equal(calls.length, 0); +}); + + +test('extractor falls back to reasoning_content when content is empty', () => { + // Observed in the wild: lfm2.5 spends its budget on `reasoning_content` + // and emits empty `content` plus empty `tool_calls`. Recover the call + // from reasoning_content as a last-resort. + const message = { + role: 'assistant', + content: '', + reasoning_content: `\n<|tool_call_start|>[write_file(path='Makefile', content='build:\\n\\t@echo building')]<|tool_call_end|>`, + }; + const r = extractFromMessage(message, SCHEMA); + assert.equal(r.patched, true); + assert.equal(r.addedCalls, 1); + assert.equal(message.tool_calls[0].function.name, 'write_file'); + // reasoning_content must be left untouched (it's debug/trace metadata). + assert.match(message.reasoning_content, /tool_call_start/); +}); + +test('extractor still prefers content over reasoning_content when both are non-empty', () => { + const message = { + role: 'assistant', + content: `<|tool_call_start|>[write_file(path='a.txt', content='primary')]<|tool_call_end|>`, + reasoning_content: `<|tool_call_start|>[write_file(path='b.txt', content='reasoning')]<|tool_call_end|>`, + }; + const r = extractFromMessage(message, SCHEMA); + assert.equal(r.addedCalls, 1); + const args = JSON.parse(message.tool_calls[0].function.arguments); + assert.equal(args.path, 'a.txt'); +}); diff --git a/test/provider_compat.test.js b/test/provider_compat.test.js index 654b2f2c..01a56615 100644 --- a/test/provider_compat.test.js +++ b/test/provider_compat.test.js @@ -226,3 +226,61 @@ test('max_tokens → max_completion_tokens for OpenAI cloud o1/o3/o4', () => { assert.equal(body4.max_tokens, 8192); assert.equal(body4.max_completion_tokens, undefined); }); + +// ─── Issue #59: Ollama on a remote server with URL-embedded basic auth ────── + +test('buildAuthHeaders converts URL basic-auth userinfo into a Basic header', () => { + const prev = { ...process.env }; + delete process.env.OPENAI_API_KEY; + delete process.env.SMALLCODE_API_KEY; + delete process.env.ANTHROPIC_API_KEY; + delete process.env.DEEPSEEK_API_KEY; + try { + const h = buildAuthHeaders({ model: { baseUrl: 'https://user:password@192.168.0.114/ollama/' } }); + const expected = 'Basic ' + Buffer.from('user:password').toString('base64'); + assert.equal(h['Authorization'], expected); + } finally { + process.env = prev; + } +}); + +test('buildAuthHeaders prefers an explicit API key over URL userinfo', () => { + const prev = { ...process.env }; + process.env.SMALLCODE_API_KEY = 'sk-local'; + try { + const h = buildAuthHeaders({ model: { baseUrl: 'https://user:password@host.local/ollama/' } }); + assert.equal(h['Authorization'], 'Bearer sk-local'); + } finally { + process.env = prev; + } +}); + +test('buildAuthHeaders leaves Authorization unset for a plain URL with no creds/key', () => { + const prev = { ...process.env }; + delete process.env.OPENAI_API_KEY; + delete process.env.SMALLCODE_API_KEY; + delete process.env.ANTHROPIC_API_KEY; + delete process.env.DEEPSEEK_API_KEY; + try { + const h = buildAuthHeaders({ model: { baseUrl: 'http://192.168.0.114:11434' } }); + assert.equal(h['Authorization'], undefined); + } finally { + process.env = prev; + } +}); + +test('buildAuthHeaders decodes percent-encoded credentials in the URL', () => { + const prev = { ...process.env }; + delete process.env.OPENAI_API_KEY; + delete process.env.SMALLCODE_API_KEY; + delete process.env.ANTHROPIC_API_KEY; + delete process.env.DEEPSEEK_API_KEY; + try { + // password is "p@ss:word" percent-encoded as p%40ss%3Aword + const h = buildAuthHeaders({ model: { baseUrl: 'https://user:p%40ss%3Aword@host/ollama/' } }); + const expected = 'Basic ' + Buffer.from('user:p@ss:word').toString('base64'); + assert.equal(h['Authorization'], expected); + } finally { + process.env = prev; + } +}); diff --git a/test/shell_session.test.js b/test/shell_session.test.js new file mode 100644 index 00000000..f8526cf9 --- /dev/null +++ b/test/shell_session.test.js @@ -0,0 +1,70 @@ +// SmallCode — persistent shell session (issue #58, parts 1 & 2) +// +// 1. zsh users: the shell must launch with bash on POSIX regardless of $SHELL, +// because the sentinel command-wrapper emits bash/POSIX syntax and passes +// bash-only flags. Honouring $SHELL=zsh broke every bash tool call. +// 2. stdin EPIPE: a dead shell must not crash the process with an unhandled +// 'error' event on stdin. + +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert'); + +const { ShellSession } = require('../src/tools/shell_session'); + +test('shell runs a command and captures output + exit code', async () => { + const sh = new ShellSession({ timeout: 15000 }); + try { + const r = await sh.run(process.platform === 'win32' ? 'echo hi' : 'echo hi'); + assert.match(r.stdout, /hi/); + assert.equal(r.exitCode, 0); + } finally { + sh.stop(); + } +}); + +test('shell launches successfully even when $SHELL is zsh (POSIX)', async () => { + if (process.platform === 'win32') return; // POSIX-only concern + const prev = process.env.SHELL; + process.env.SHELL = '/usr/bin/zsh'; + const sh = new ShellSession({ timeout: 15000 }); + try { + const started = await sh.start(); + assert.equal(started, true, 'shell should start with bash, not zsh --norc'); + const r = await sh.run('echo zsh-ok'); + assert.match(r.stdout, /zsh-ok/); + assert.equal(r.exitCode, 0); + } finally { + sh.stop(); + if (prev === undefined) delete process.env.SHELL; + else process.env.SHELL = prev; + } +}); + +test('a stopped shell auto-restarts on the next run without crashing', async () => { + const sh = new ShellSession({ timeout: 15000 }); + try { + await sh.run('echo first'); + sh.stop(); // kill the shell + // Next run must transparently restart rather than throwing / crashing. + const r = await sh.run(process.platform === 'win32' ? 'echo second' : 'echo second'); + assert.match(r.stdout, /second/); + assert.equal(r.exitCode, 0); + } finally { + sh.stop(); + } +}); + +test('stdin has an error listener attached (no unhandled EPIPE)', async () => { + const sh = new ShellSession({ timeout: 15000 }); + try { + await sh.start(); + assert.ok(sh.proc, 'process should be spawned'); + // The fix attaches an 'error' listener to stdin at spawn time. + assert.ok(sh.proc.stdin.listenerCount('error') >= 1, + 'stdin must have an error listener to swallow async EPIPE'); + } finally { + sh.stop(); + } +}); diff --git a/test/web_prompt.test.js b/test/web_prompt.test.js new file mode 100644 index 00000000..f8ef4418 --- /dev/null +++ b/test/web_prompt.test.js @@ -0,0 +1,62 @@ +// SmallCode — web-research prompt advertisement (issue #58, part 3) +// +// Small models trust the prose tool list in the system prompt over the raw +// `tools` array. When SMALLCODE_WEB_BROWSE=true they must be told the web +// tools exist, otherwise they refuse research tasks. These tests pin the +// behaviour of model_client.js buildSystemPrompt(). + +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert'); + +const { buildSystemPrompt } = require('../bin/model_client'); + +function baseCtx() { + return { + config: { model: { name: 'test-model', baseUrl: 'http://localhost:1234/v1', provider: 'openai' } }, + conversationHistory: [{ role: 'user', content: 'look up the latest node release' }], + currentTaskType: 'coding', + // No memory/skill/plugin context for these tests. + memoryStore: null, + skillManager: null, + pluginLoader: null, + }; +} + +test('web tools are advertised when SMALLCODE_WEB_BROWSE=true', () => { + const prev = process.env.SMALLCODE_WEB_BROWSE; + process.env.SMALLCODE_WEB_BROWSE = 'true'; + try { + const prompt = buildSystemPrompt(baseCtx()); + assert.match(prompt, /WEB RESEARCH/); + assert.match(prompt, /web_search/); + assert.match(prompt, /web_fetch/); + } finally { + if (prev === undefined) delete process.env.SMALLCODE_WEB_BROWSE; + else process.env.SMALLCODE_WEB_BROWSE = prev; + } +}); + +test('web tools are NOT advertised when SMALLCODE_WEB_BROWSE is unset', () => { + const prev = process.env.SMALLCODE_WEB_BROWSE; + delete process.env.SMALLCODE_WEB_BROWSE; + try { + const prompt = buildSystemPrompt(baseCtx()); + assert.doesNotMatch(prompt, /WEB RESEARCH/); + } finally { + if (prev !== undefined) process.env.SMALLCODE_WEB_BROWSE = prev; + } +}); + +test('web tools are NOT advertised when SMALLCODE_WEB_BROWSE=false', () => { + const prev = process.env.SMALLCODE_WEB_BROWSE; + process.env.SMALLCODE_WEB_BROWSE = 'false'; + try { + const prompt = buildSystemPrompt(baseCtx()); + assert.doesNotMatch(prompt, /WEB RESEARCH/); + } finally { + if (prev === undefined) delete process.env.SMALLCODE_WEB_BROWSE; + else process.env.SMALLCODE_WEB_BROWSE = prev; + } +});