little-coder 1.8.3 → 1.8.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,6 +8,16 @@ import { harnessIntervention } from "../_shared/intervention.ts";
8
8
  // the headline Qwen3.6-35B-A3B path, which uses native tool calling. When
9
9
  // extracted calls ARE detected, we log them via ctx.ui.notify and queue a
10
10
  // follow-up nudge for the next turn.
11
+ //
12
+ // One format is handled differently: LFM2/Liquid "Pythonic" tool calls
13
+ // (`<|tool_call_start|>[Read(path='…')]<|tool_call_end|>`, issue #42). Pythonic
14
+ // IS that model's native channel, so a "use native tool calls" nudge can't move
15
+ // it to another format — it would just re-emit the same text every turn and
16
+ // loop. little-coder also can't execute the calls itself (pi exposes no
17
+ // extension API to run a tool + synthesize its result). So for that format we
18
+ // surface a single, accurate diagnostic pointing at the real fix — serving
19
+ // llama.cpp with `--jinja` and the model's chat template, which parses the
20
+ // calls into native tool_calls upstream — instead of looping a futile nudge.
11
21
 
12
22
  function extractAssistantText(message: any): string {
13
23
  if (!message) return "";
@@ -26,6 +36,10 @@ function hasNativeToolCalls(message: any): boolean {
26
36
  }
27
37
 
28
38
  export default function (pi: ExtensionAPI) {
39
+ // The --jinja diagnostic is shown once per session — every LFM2 turn would
40
+ // otherwise repeat it, which is noise once the user knows.
41
+ let liquidNotified = false;
42
+
29
43
  pi.on("turn_end", async (event, ctx) => {
30
44
  const message = (event as any).message;
31
45
  if (!message) return;
@@ -37,21 +51,37 @@ export default function (pi: ExtensionAPI) {
37
51
  const calls = parseTextToolCalls(text);
38
52
  if (calls.length === 0) return;
39
53
 
40
- const names = calls.map((c) => c.name).join(", ");
41
- harnessIntervention(
42
- ctx,
43
- `the model wrote ${calls.length} tool call(s) as text [${names}] nudging it back to native tool calls.`,
44
- );
45
-
46
- // Queue a follow-up that will be delivered after the agent finishes.
47
- // This nudges the model to use native tool calling on its next turn
48
- // rather than emitting fenced blocks in text.
49
- pi.sendUserMessage(
50
- "Your previous response embedded tool calls inside text (e.g. fenced ```tool blocks or <tool_call> tags). " +
51
- "Please re-issue them as NATIVE tool calls. If the intended calls were: " +
52
- calls.map((c) => `${c.name}(${JSON.stringify(c.input)})`).join("; ") +
53
- " — please execute them now using your tool-call channel, not text.",
54
- { deliverAs: "followUp" },
55
- );
54
+ const liquidCalls = calls.filter((c) => c.format === "liquid");
55
+ const otherCalls = calls.filter((c) => c.format !== "liquid");
56
+
57
+ // LFM2/Liquid Pythonic format: inform once, don't nudge (see header note).
58
+ if (liquidCalls.length > 0 && !liquidNotified) {
59
+ liquidNotified = true;
60
+ const names = liquidCalls.map((c) => c.name).join(", ");
61
+ harnessIntervention(
62
+ ctx,
63
+ `the model emitted ${liquidCalls.length} Pythonic tool call(s) as text [${names}] (LFM2/Liquid format). ` +
64
+ `little-coder can't execute these directly serve llama.cpp with \`--jinja\` and the model's MATCHING ` +
65
+ `chat template (not the GGUF's embedded one) so tool calls parse into native tool_calls. ` +
66
+ `See README troubleshooting / issue #42.`,
67
+ );
68
+ }
69
+
70
+ // Fenced / <tool_call> / bare-JSON formats: nudge the model back to native
71
+ // tool calling (it has a native channel; this format was a slip).
72
+ if (otherCalls.length > 0) {
73
+ const names = otherCalls.map((c) => c.name).join(", ");
74
+ harnessIntervention(
75
+ ctx,
76
+ `the model wrote ${otherCalls.length} tool call(s) as text [${names}] — nudging it back to native tool calls.`,
77
+ );
78
+ pi.sendUserMessage(
79
+ "Your previous response embedded tool calls inside text (e.g. fenced ```tool blocks, <tool_call> tags, or bare JSON). " +
80
+ "Please re-issue them as NATIVE tool calls. If the intended calls were: " +
81
+ otherCalls.map((c) => `${c.name}(${JSON.stringify(c.input)})`).join("; ") +
82
+ " — please execute them now using your tool-call channel, not text.",
83
+ { deliverAs: "followUp" },
84
+ );
85
+ }
56
86
  });
57
87
  }
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { repairJson, parseTextToolCalls, escapeNewlinesInJsonStrings } from "./parser.ts";
2
+ import { repairJson, parseTextToolCalls, parseLiquidToolCalls, escapeNewlinesInJsonStrings } from "./parser.ts";
3
3
 
4
4
  describe("repairJson", () => {
5
5
  it("direct parse on valid JSON", () => {
@@ -87,4 +87,126 @@ describe("parseTextToolCalls", () => {
87
87
  it("empty on plain text", () => {
88
88
  expect(parseTextToolCalls("just regular text, no tools here")).toEqual([]);
89
89
  });
90
+
91
+ it("extracts an LFM2/Liquid Pythonic call via parseTextToolCalls and tags format", () => {
92
+ const text = "<|tool_call_start|>[Read(path='/a.c')]<|tool_call_end|>";
93
+ const calls = parseTextToolCalls(text);
94
+ expect(calls.length).toBe(1);
95
+ expect(calls[0].name).toBe("Read");
96
+ expect(calls[0].input).toEqual({ path: "/a.c" });
97
+ expect(calls[0].format).toBe("liquid");
98
+ });
99
+ });
100
+
101
+ describe("parseLiquidToolCalls (LFM2 / Liquid Pythonic format)", () => {
102
+ it("canonical single call wrapped in special tokens", () => {
103
+ const calls = parseLiquidToolCalls("<|tool_call_start|>[Read(path='/home/user/foo.c')]<|tool_call_end|>");
104
+ expect(calls).toEqual([{ id: "call_text_0", name: "Read", input: { path: "/home/user/foo.c" }, format: "liquid" }]);
105
+ });
106
+
107
+ it("recovers the exact issue #42 leak shape (start token + [ stripped, end + im_end trailing)", () => {
108
+ // From the issue: `Failed to parse input at pos 57: Read(path='/home/user/foo.c')]<|tool_call_end|><|im_end|>`
109
+ const text = "Read(path='/home/user/foo.c')]<|tool_call_end|><|im_end|>";
110
+ const calls = parseLiquidToolCalls(text);
111
+ expect(calls.length).toBe(1);
112
+ expect(calls[0].name).toBe("Read");
113
+ expect(calls[0].input).toEqual({ path: "/home/user/foo.c" });
114
+ });
115
+
116
+ it("multiple calls in one list", () => {
117
+ const text = "<|tool_call_start|>[Read(path='/a'), Bash(command='ls -la')]<|tool_call_end|>";
118
+ const calls = parseLiquidToolCalls(text);
119
+ expect(calls.map((c) => c.name)).toEqual(["Read", "Bash"]);
120
+ expect(calls[1].input).toEqual({ command: "ls -la" });
121
+ });
122
+
123
+ it("commas and parens INSIDE a string value don't split args/calls", () => {
124
+ const text = "<|tool_call_start|>[Bash(command='echo (hi), then ls')]<|tool_call_end|>";
125
+ const calls = parseLiquidToolCalls(text);
126
+ expect(calls.length).toBe(1);
127
+ expect(calls[0].input).toEqual({ command: "echo (hi), then ls" });
128
+ });
129
+
130
+ it("double-quoted string values (model variant)", () => {
131
+ const calls = parseLiquidToolCalls('<|tool_call_start|>[Read(path="/a.c")]<|tool_call_end|>');
132
+ expect(calls[0].input).toEqual({ path: "/a.c" });
133
+ });
134
+
135
+ it("Python scalar types: int, float, True/False, None", () => {
136
+ const text =
137
+ "<|tool_call_start|>[Conf(n=3, ratio=1.5, neg=-2, flag=True, off=False, none=None)]<|tool_call_end|>";
138
+ const calls = parseLiquidToolCalls(text);
139
+ expect(calls[0].input).toEqual({ n: 3, ratio: 1.5, neg: -2, flag: true, off: false, none: null });
140
+ });
141
+
142
+ it("list arg (Python repr, single quotes, internal commas)", () => {
143
+ const text = "<|tool_call_start|>[Grep(paths=['a.py', 'b.py'], pattern='x')]<|tool_call_end|>";
144
+ const calls = parseLiquidToolCalls(text);
145
+ expect(calls[0].input).toEqual({ paths: ["a.py", "b.py"], pattern: "x" });
146
+ });
147
+
148
+ it("dict arg rendered as JSON (tojson)", () => {
149
+ const text = '<|tool_call_start|>[Run(opts={"x": 1, "y": "z"})]<|tool_call_end|>';
150
+ const calls = parseLiquidToolCalls(text);
151
+ expect(calls[0].input).toEqual({ opts: { x: 1, y: "z" } });
152
+ });
153
+
154
+ it("no-arg call", () => {
155
+ const calls = parseLiquidToolCalls("<|tool_call_start|>[ListDir()]<|tool_call_end|>");
156
+ expect(calls).toEqual([{ id: "call_text_0", name: "ListDir", input: {}, format: "liquid" }]);
157
+ });
158
+
159
+ it("truncated tail: missing closing paren / bracket / quote", () => {
160
+ const text = "<|tool_call_start|>[Read(path='/home/user/foo.c";
161
+ const calls = parseLiquidToolCalls(text);
162
+ expect(calls.length).toBe(1);
163
+ expect(calls[0].name).toBe("Read");
164
+ expect(calls[0].input).toEqual({ path: "/home/user/foo.c" });
165
+ });
166
+
167
+ it("bare whole-message bracket list (no special tokens)", () => {
168
+ const calls = parseLiquidToolCalls("[Read(path='/a'), Read(path='/b')]");
169
+ expect(calls.map((c) => c.input.path)).toEqual(["/a", "/b"]);
170
+ });
171
+
172
+ it("recovers REAL LFM2.5-8B-A1B output: <think>…</think> then a bare, double-quoted call list", () => {
173
+ // Captured verbatim from llama.cpp serving LiquidAI/LFM2.5-8B-A1B-Q4_K_M:
174
+ // the model reasons in <think>…</think>, emits NO special tokens, and uses
175
+ // DOUBLE quotes — none of which the first cut of this parser handled.
176
+ const real =
177
+ '<think>\nOkay, the user wants two things. First read the file, then run the ls command.\n' +
178
+ 'For Read the parameter is "path"; for Bash the command is "ls -la /tmp".\n</think>' +
179
+ '[Read(path="/home/user/foo.c"), Bash(command="ls -la /tmp")]';
180
+ const calls = parseLiquidToolCalls(real);
181
+ expect(calls.map((c) => c.name)).toEqual(["Read", "Bash"]);
182
+ expect(calls[0].input).toEqual({ path: "/home/user/foo.c" });
183
+ expect(calls[1].input).toEqual({ command: "ls -la /tmp" });
184
+ });
185
+
186
+ it("does not fire while the model is still inside an unclosed <think> block", () => {
187
+ expect(parseLiquidToolCalls("<think>\nI should call [Read(path='/a')] next...")).toEqual([]);
188
+ });
189
+
190
+ it("preserves spaces inside string values, trims around args", () => {
191
+ const calls = parseLiquidToolCalls("<|tool_call_start|>[Bash( command = 'git status' )]<|tool_call_end|>");
192
+ expect(calls[0].input).toEqual({ command: "git status" });
193
+ });
194
+
195
+ // ── precision: must NOT fire on ordinary prose ──────────────────────────────
196
+ it("ignores plain prose", () => {
197
+ expect(parseLiquidToolCalls("I'll read the file and report back.")).toEqual([]);
198
+ });
199
+
200
+ it("ignores a markdown/JSON array that isn't a call list", () => {
201
+ expect(parseLiquidToolCalls("[1, 2, 3]")).toEqual([]);
202
+ expect(parseLiquidToolCalls('["a", "b"]')).toEqual([]);
203
+ });
204
+
205
+ it("ignores a bracketed phrase in prose that isn't a clean call list", () => {
206
+ expect(parseLiquidToolCalls("[see the foo() helper](http://x) for details")).toEqual([]);
207
+ });
208
+
209
+ it("does not fire on a function-call-looking phrase mid-sentence (no tokens, not whole-message)", () => {
210
+ expect(parseLiquidToolCalls("then I called Read(path='/a') to inspect it")).toEqual([]);
211
+ });
90
212
  });
@@ -75,11 +75,21 @@ export interface ExtractedCall {
75
75
  id: string;
76
76
  name: string;
77
77
  input: Record<string, unknown>;
78
+ /** Which text encoding the call was recovered from. Lets the extension treat
79
+ * the LFM2/Liquid "Pythonic" format differently from the JSON-based ones:
80
+ * nudging a model back to "native" tool calls is futile when Pythonic IS its
81
+ * native channel, so that path informs once instead of looping. */
82
+ format?: "fenced" | "tag" | "bare" | "liquid";
78
83
  }
79
84
 
80
85
  export function parseTextToolCalls(text: string): ExtractedCall[] {
81
86
  const calls: ExtractedCall[] = [];
82
87
 
88
+ // Pattern 0: LFM2 / Liquid "Pythonic" tool calls. Checked first — the
89
+ // <|tool_call_*|> special tokens are unambiguous and the format never
90
+ // overlaps the JSON-based patterns below (issue #42).
91
+ calls.push(...parseLiquidToolCalls(text));
92
+
83
93
  // Pattern 1: ```tool ... ``` or ```json ... ```
84
94
  const fenceRe = /```(?:tool|json)\s*\n([\s\S]*?)\n```/g;
85
95
  let m: RegExpExecArray | null;
@@ -90,6 +100,7 @@ export function parseTextToolCalls(text: string): ExtractedCall[] {
90
100
  id: `call_text_${calls.length}`,
91
101
  name: data.name,
92
102
  input: (data.input ?? data.parameters ?? data.args ?? {}) as Record<string, unknown>,
103
+ format: "fenced",
93
104
  });
94
105
  }
95
106
  }
@@ -103,6 +114,7 @@ export function parseTextToolCalls(text: string): ExtractedCall[] {
103
114
  id: `call_text_${calls.length}`,
104
115
  name: data.name,
105
116
  input: (data.input ?? data.parameters ?? data.args ?? {}) as Record<string, unknown>,
117
+ format: "tag",
106
118
  });
107
119
  }
108
120
  }
@@ -117,6 +129,7 @@ export function parseTextToolCalls(text: string): ExtractedCall[] {
117
129
  id: `call_text_${calls.length}`,
118
130
  name: data.name,
119
131
  input: (data.input ?? data.parameters ?? {}) as Record<string, unknown>,
132
+ format: "bare",
120
133
  });
121
134
  }
122
135
  }
@@ -124,3 +137,192 @@ export function parseTextToolCalls(text: string): ExtractedCall[] {
124
137
 
125
138
  return calls;
126
139
  }
140
+
141
+ // ── LFM2 / Liquid "Pythonic" tool-call format ───────────────────────────────
142
+ // LiquidAI LFM2 models (issue #42) emit tool calls as a Python list of function
143
+ // calls wrapped in special tokens, e.g.
144
+ // <|tool_call_start|>[Read(path='/a.c'), Grep(pattern='x', path='.')]<|tool_call_end|>
145
+ // Argument values follow the model's chat-template `format_arg_value` macro:
146
+ // string -> single quotes 'val' (the template does NOT escape inner quotes)
147
+ // dict -> JSON object {"k": "v"}
148
+ // else -> Python str(): 123, 1.5, True, False, None, ['a', 'b']
149
+ // Served WITHOUT llama.cpp's `--jinja`, these are never parsed into native
150
+ // tool_calls and leak into assistant TEXT — often with the start token and its
151
+ // `[` stripped and `]<|tool_call_end|><|im_end|>` trailing (the exact shape in
152
+ // the issue's error). We recover them best-effort so the harness can react with
153
+ // an accurate diagnostic instead of a cryptic parse failure.
154
+
155
+ const LIQUID_START = "<|tool_call_start|>";
156
+ const LIQUID_END = "<|tool_call_end|>";
157
+
158
+ /** Split `s` on a single-char separator, ignoring separators inside quotes
159
+ * (single or double, with `\` escaping) or inside (), [], {} of any depth. */
160
+ function splitTopLevel(s: string, sep: string): string[] {
161
+ const parts: string[] = [];
162
+ let depth = 0;
163
+ let quote: string | null = null;
164
+ let esc = false;
165
+ let cur = "";
166
+ for (const c of s) {
167
+ cur += c;
168
+ if (quote) {
169
+ if (esc) esc = false;
170
+ else if (c === "\\") esc = true;
171
+ else if (c === quote) quote = null;
172
+ continue;
173
+ }
174
+ if (c === "'" || c === '"') quote = c;
175
+ else if (c === "(" || c === "[" || c === "{") depth++;
176
+ else if (c === ")" || c === "]" || c === "}") depth--;
177
+ else if (c === sep && depth === 0) {
178
+ parts.push(cur.slice(0, -1));
179
+ cur = "";
180
+ }
181
+ }
182
+ parts.push(cur);
183
+ return parts;
184
+ }
185
+
186
+ /** Index of the first top-level occurrence of `ch` (quote/bracket-aware), or -1. */
187
+ function topLevelIndexOf(s: string, ch: string): number {
188
+ let depth = 0;
189
+ let quote: string | null = null;
190
+ let esc = false;
191
+ for (let i = 0; i < s.length; i++) {
192
+ const c = s[i];
193
+ if (quote) {
194
+ if (esc) esc = false;
195
+ else if (c === "\\") esc = true;
196
+ else if (c === quote) quote = null;
197
+ continue;
198
+ }
199
+ if (c === "'" || c === '"') quote = c;
200
+ else if (c === "(" || c === "[" || c === "{") depth++;
201
+ else if (c === ")" || c === "]" || c === "}") depth--;
202
+ else if (c === ch && depth === 0) return i;
203
+ }
204
+ return -1;
205
+ }
206
+
207
+ function unescapePy(s: string): string {
208
+ return s.replace(/\\(['"\\nrt])/g, (_, c) => (c === "n" ? "\n" : c === "t" ? "\t" : c === "r" ? "\r" : c));
209
+ }
210
+
211
+ /** Coerce one Python-literal argument value (as rendered by `format_arg_value`)
212
+ * into a JS value. Best-effort and total — never throws; an unrecognized token
213
+ * falls through as a bare string so no data is lost. Returns undefined only for
214
+ * an empty slot (e.g. a trailing comma). */
215
+ function parsePyValue(raw: string): unknown {
216
+ const s = raw.trim();
217
+ if (!s) return undefined;
218
+ const c0 = s[0];
219
+ // String — strip the outer matching quote. Slicing first/last (rather than
220
+ // unescaping a closing quote) tolerates the template's unescaped inner quotes
221
+ // for the common case where the value still begins and ends with the quote.
222
+ if (c0 === "'" || c0 === '"') {
223
+ const inner = s[s.length - 1] === c0 && s.length >= 2 ? s.slice(1, -1) : s.slice(1);
224
+ return unescapePy(inner);
225
+ }
226
+ if (c0 === "{") {
227
+ const obj = repairJson(s);
228
+ return "_raw" in obj && Object.keys(obj).length === 1 ? s : obj;
229
+ }
230
+ if (c0 === "[") return parsePyList(s);
231
+ if (s === "True" || s.toLowerCase() === "true") return true;
232
+ if (s === "False" || s.toLowerCase() === "false") return false;
233
+ if (s === "None" || s.toLowerCase() === "null" || s.toLowerCase() === "none") return null;
234
+ if (/^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/.test(s)) return Number(s);
235
+ return s; // bareword / unquoted — keep verbatim
236
+ }
237
+
238
+ function parsePyList(s: string): unknown[] {
239
+ const inner = s.trim().replace(/^\[/, "").replace(/\]$/, "");
240
+ if (!inner.trim()) return [];
241
+ return splitTopLevel(inner, ",")
242
+ .map(parsePyValue)
243
+ .filter((v) => v !== undefined);
244
+ }
245
+
246
+ /** Parse a `name(arg=val, ...)` Python call. Tolerates a truncated tail (a
247
+ * missing closing paren). Returns null when there's no `name(` head. */
248
+ function parsePyCall(raw: string): { name: string; input: Record<string, unknown> } | null {
249
+ const s = raw.trim();
250
+ const open = s.indexOf("(");
251
+ if (open < 0) return null;
252
+ const name = s.slice(0, open).trim();
253
+ if (!/^[A-Za-z_]\w*$/.test(name)) return null;
254
+ // Find the matching close paren (quote/bracket-aware); fall back to end on truncation.
255
+ let depth = 0;
256
+ let quote: string | null = null;
257
+ let esc = false;
258
+ let end = -1;
259
+ for (let i = open; i < s.length; i++) {
260
+ const c = s[i];
261
+ if (quote) {
262
+ if (esc) esc = false;
263
+ else if (c === "\\") esc = true;
264
+ else if (c === quote) quote = null;
265
+ continue;
266
+ }
267
+ if (c === "'" || c === '"') quote = c;
268
+ else if (c === "(") depth++;
269
+ else if (c === ")") {
270
+ depth--;
271
+ if (depth === 0) {
272
+ end = i;
273
+ break;
274
+ }
275
+ }
276
+ }
277
+ const argsBlob = end >= 0 ? s.slice(open + 1, end) : s.slice(open + 1);
278
+ const input: Record<string, unknown> = {};
279
+ for (const part of splitTopLevel(argsBlob, ",")) {
280
+ const seg = part.trim();
281
+ if (!seg) continue;
282
+ const eq = topLevelIndexOf(seg, "=");
283
+ if (eq < 0) continue; // positional/garbage — LFM2 always emits kwargs; skip safely
284
+ const key = seg.slice(0, eq).trim();
285
+ if (!/^[A-Za-z_]\w*$/.test(key)) continue;
286
+ const val = parsePyValue(seg.slice(eq + 1));
287
+ if (val !== undefined) input[key] = val;
288
+ }
289
+ return { name, input };
290
+ }
291
+
292
+ /** Recover LFM2/Liquid Pythonic tool calls from assistant text. High-precision:
293
+ * fires on the `<|tool_call_*|>` special tokens, or — without them — only when
294
+ * the whole message is a `[...]` bracket list, since every element must still
295
+ * parse as a `name(...)` call. */
296
+ export function parseLiquidToolCalls(text: string): ExtractedCall[] {
297
+ const hasStart = text.includes(LIQUID_START);
298
+ const hasEnd = text.includes(LIQUID_END);
299
+ let region: string;
300
+ if (hasStart || hasEnd) {
301
+ let s = text;
302
+ if (hasStart) s = s.slice(s.indexOf(LIQUID_START) + LIQUID_START.length);
303
+ if (s.includes(LIQUID_END)) s = s.slice(0, s.indexOf(LIQUID_END));
304
+ region = s;
305
+ } else {
306
+ // No special tokens (some llama.cpp builds/templates emit the bare list).
307
+ // Reasoning LFM2 models put the call list AFTER a <think>…</think> block —
308
+ // e.g. `</think>[Read(path="/a"), Bash(command="ls")]` (verified against
309
+ // LFM2.5-8B-A1B). Strip a leading think block, then require the remainder to
310
+ // be exactly a `[…]` list so prose can't trip it.
311
+ const t = text.trim().replace(/^<think>[\s\S]*?<\/think>\s*/, "").trim();
312
+ if (!(t.startsWith("[") && t.endsWith("]"))) return [];
313
+ region = t;
314
+ }
315
+ // Drop any leftover special tokens, then one wrapping [ ... ] of the call list.
316
+ region = region.replace(/<\|tool_call_(?:start|end)\|>/g, "").replace(/<\|im_end\|>/g, "").trim();
317
+ if (region.startsWith("[")) region = region.slice(1);
318
+ if (region.endsWith("]")) region = region.slice(0, -1);
319
+ region = region.trim();
320
+ if (!region) return [];
321
+
322
+ const calls: ExtractedCall[] = [];
323
+ for (const part of splitTopLevel(region, ",")) {
324
+ const call = parsePyCall(part);
325
+ if (call) calls.push({ id: `call_text_${calls.length}`, name: call.name, input: call.input, format: "liquid" });
326
+ }
327
+ return calls;
328
+ }
package/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  All notable changes to little-coder are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and little-coder's public interface (CLI, providers, tools, skills) follows semver starting at `v0.0.1` post-rename.
4
4
 
5
+ ## [v1.8.4] — 2026-06-08
6
+
7
+ ### Added
8
+ - **`output-parser` now recognizes LFM2 / Liquid "Pythonic" tool calls** ([#42](https://github.com/itayinbarr/little-coder/issues/42)). LiquidAI LFM2 models emit tool calls as a Python list wrapped in special tokens — `<|tool_call_start|>[Read(path='/a.c'), Bash(command='ls -la')]<|tool_call_end|>` — a format neither pi's native path nor the existing fenced/`<tool_call>`/bare-JSON parsers understood. New `parseLiquidToolCalls()` recovers them best-effort: single **and** double quotes, dict args (`{"k":"v"}`), list args (`['a','b']`), `True`/`False`/`None`, ints/floats, commas/parens **inside** string values, truncated tails (missing `)`/`]`/quote), the issue's exact leak shape (start token + `[` stripped, `]<|tool_call_end|><|im_end|>` trailing), and the real-world `<think>…</think>[calls]` shape — all with a precision guard so ordinary prose never trips it. Each recovered call is tagged `format: "liquid"`; the extension surfaces a single, accurate diagnostic for that format instead of the futile "use native tool calls" nudge (Pythonic *is* LFM2's native channel, so nudging would just loop). 20 new parser tests, including one built from verbatim LFM2.5-8B-A1B output.
9
+
10
+ ### Fixed / Documentation
11
+ - **Diagnosed and documented the actual `Failed to parse input at pos N: …<|tool_call_end|>` failure** ([#42](https://github.com/itayinbarr/little-coder/issues/42)). The error is *server-side*: llama.cpp's `chat.cpp` tool-call parser chokes when the chat template doesn't match it — typically the GGUF's **embedded** template, which renders tools as a plain `List of tools: […]` blob without the `<|tool_list_start|>` / `<|tool_call_start|>` special tokens the parser expects. Verified end-to-end with `LiquidAI/LFM2.5-8B-A1B-Q4_K_M`: the embedded template reproduces the error and the tool never runs, while serving with `--jinja --chat-template-file LFM2-8B-A1B.jinja` (the matching template, with the special tokens) parses calls into native `tool_calls` and tools execute normally. New Troubleshooting entry with the exact fix.
12
+
13
+ ### Notes for upgraders
14
+ - No CLI-flag or public-API changes. If you run an LFM2/Liquid model, serve llama.cpp with `--jinja` and the model's matching chat template (see Troubleshooting). The parser change only adds recovery + a clearer diagnostic for builds that leak the calls as text.
15
+
16
+ ---
17
+
5
18
  ## [v1.8.3] — 2026-06-08
6
19
 
7
20
  ### Fixed
package/README.md CHANGED
@@ -294,6 +294,8 @@ This is where the scaffolding work now compounds: knowledge injection/selection,
294
294
 
295
295
  **Image attachment is accepted but the request returns 4xx** — your llama-server is running without a vision projector. Re-launch it with `--mmproj ~/models/mmproj-F16.gguf` (or another mmproj variant from the same GGUF repo). The `--list-models` `images` column reflects what the client *will attempt to send*, not what the server can answer; the projector is what gives the model eyes.
296
296
 
297
+ **`Failed to parse input at pos N: SomeTool(arg='…')]<|tool_call_end|>` (LFM2 / Liquid models)** — the model is emitting its native *Pythonic* tool calls (`<|tool_call_start|>[Read(path='…')]<|tool_call_end|>`), but llama.cpp's tool-call parser is choking on them — usually because the **chat template doesn't match the parser**. The GGUF's *embedded* template often renders tools as a plain `List of tools: […]` blob without the `<|tool_list_start|>` / `<|tool_call_start|>` special tokens the parser expects. Fix: serve with `--jinja` and the model's **proper** chat template, e.g. `llama-server -m LFM2.5-8B-A1B-Q4_K_M.gguf --jinja --chat-template-file LFM2-8B-A1B.jinja` (templates ship under `llama.cpp/models/templates/`). With the matching template, llama.cpp parses the calls into native `tool_calls` and tools execute normally — verified end-to-end with LFM2.5-8B-A1B. If your build still leaks the calls as plain text, little-coder's `output-parser` recognizes the format and surfaces this same diagnostic instead of a cryptic error (issue [#42](https://github.com/itayinbarr/little-coder/issues/42)).
298
+
297
299
  **No API key env var warning** — pi expects *some* key even for local providers. Export `LLAMACPP_API_KEY=noop` (or `OLLAMA_API_KEY=noop`) before launching.
298
300
 
299
301
  **No pi "Update Available" banner** — that's intentional. little-coder defaults `PI_SKIP_VERSION_CHECK=1` so the bundled pi runtime doesn't nag about updating itself; little-coder pins pi to a known-good version per release. If you actually want the banner back, `export PI_SKIP_VERSION_CHECK=0` before launching.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "little-coder",
3
- "version": "1.8.3",
3
+ "version": "1.8.4",
4
4
  "description": "A pi-based coding agent optimized for small local language models. Reproduces the whitepaper's scaffold-model-fit adaptations as pi extensions.",
5
5
  "homepage": "https://github.com/itayinbarr/little-coder",
6
6
  "repository": {