little-coder 1.8.3 → 1.9.0
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.
- package/.pi/extensions/branding/branding.test.ts +42 -0
- package/.pi/extensions/branding/index.ts +56 -10
- package/.pi/extensions/extra-tools/glob.ts +3 -3
- package/.pi/extensions/extra-tools/index.ts +1 -1
- package/.pi/extensions/output-parser/index.ts +46 -16
- package/.pi/extensions/output-parser/parser.test.ts +123 -1
- package/.pi/extensions/output-parser/parser.ts +202 -0
- package/.pi/extensions/plan-mode/index.ts +377 -0
- package/.pi/extensions/plan-mode/plan-mode.test.ts +49 -0
- package/.pi/extensions/plan-mode/status.ts +79 -0
- package/.pi/extensions/prompt-history/index.ts +154 -0
- package/.pi/extensions/prompt-history/prompt-history.test.ts +72 -0
- package/.pi/extensions/read-guard-edit/index.ts +89 -0
- package/.pi/extensions/read-guard-edit/read-guard-edit.test.ts +100 -0
- package/.pi/extensions/skill-inject/index.ts +3 -0
- package/.pi/extensions/skill-inject/selector.test.ts +2 -2
- package/.pi/extensions/subagent/index.ts +201 -0
- package/.pi/extensions/subagent/live-spawn.test.ts +47 -0
- package/.pi/extensions/subagent/spawn.test.ts +97 -0
- package/.pi/extensions/subagent/spawn.ts +373 -0
- package/.pi/extensions/subagent/tracker.ts +139 -0
- package/AGENTS.md +5 -0
- package/CHANGELOG.md +36 -0
- package/README.md +19 -3
- package/bin/little-coder.mjs +56 -5
- package/package.json +2 -2
- package/skills/tools/dispatch.md +38 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { deriveSessionName } from "./index.ts";
|
|
3
|
+
|
|
4
|
+
describe("deriveSessionName", () => {
|
|
5
|
+
it("uses at most the first 4 words, with an ellipsis when there are more", () => {
|
|
6
|
+
expect(deriveSessionName("add a dark mode toggle to settings")).toBe("add a dark mode…");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("keeps prompts of 4 words or fewer whole (no ellipsis)", () => {
|
|
10
|
+
expect(deriveSessionName("add dark mode")).toBe("add dark mode");
|
|
11
|
+
expect(deriveSessionName("one two three four")).toBe("one two three four");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("never slices a word mid-way", () => {
|
|
15
|
+
const name = deriveSessionName(
|
|
16
|
+
"implement comprehensive authentication authorization subsystem now please",
|
|
17
|
+
)!;
|
|
18
|
+
// every space-separated token is a complete word from the input
|
|
19
|
+
for (const w of name.replace(/…$/, "").split(" ")) {
|
|
20
|
+
expect("implement comprehensive authentication authorization subsystem now please").toContain(w);
|
|
21
|
+
}
|
|
22
|
+
expect(name.endsWith("…")).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("takes only the first line", () => {
|
|
26
|
+
expect(deriveSessionName("fix the bug\nmore details here")).toBe("fix the bug");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("collapses surrounding whitespace", () => {
|
|
30
|
+
expect(deriveSessionName(" refactor the parser ")).toBe("refactor the parser");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("ignores slash-commands and bash lines", () => {
|
|
34
|
+
expect(deriveSessionName("/resume")).toBeUndefined();
|
|
35
|
+
expect(deriveSessionName("!ls -la")).toBeUndefined();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("returns undefined for empty input", () => {
|
|
39
|
+
expect(deriveSessionName(" ")).toBeUndefined();
|
|
40
|
+
expect(deriveSessionName("")).toBeUndefined();
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -70,8 +70,28 @@ function buildHeader(theme: Theme): string[] {
|
|
|
70
70
|
return ["", logo, tagline, "", hints, ""];
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
|
|
74
|
-
|
|
73
|
+
// Derive a short, human session name from the first user prompt. Returns
|
|
74
|
+
// undefined when there's nothing worth naming (empty, or a command/bash line).
|
|
75
|
+
// Kept pure + exported so the slug rules are unit-testable.
|
|
76
|
+
export function deriveSessionName(text: string): string | undefined {
|
|
77
|
+
const trimmed = text.trim();
|
|
78
|
+
// Slash-commands and `!`-bash aren't tasks — don't name the session after them.
|
|
79
|
+
if (!trimmed || trimmed.startsWith("/") || trimmed.startsWith("!")) return undefined;
|
|
80
|
+
// First line only, first 4 words — cut on word boundaries so it never slices
|
|
81
|
+
// a word mid-way. A "…" is appended only if there were more words.
|
|
82
|
+
const firstLine = trimmed.split(/\r?\n/, 1)[0];
|
|
83
|
+
const allWords = firstLine.split(/\s+/).filter(Boolean);
|
|
84
|
+
if (allWords.length === 0) return undefined;
|
|
85
|
+
const words = allWords.slice(0, 4);
|
|
86
|
+
return allWords.length > words.length ? `${words.join(" ")}…` : words.join(" ");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Title shows the session's name once it has one, else the cwd basename — so a
|
|
90
|
+
// `/resume`d or `/name`d session is identifiable in the terminal tab, and
|
|
91
|
+
// switching sessions updates the tab (session_start re-asserts on resume).
|
|
92
|
+
function setTitle(setter: (t: string) => void, cwd: string, sessionName?: string): void {
|
|
93
|
+
const label = sessionName && sessionName.length > 0 ? sessionName : basename(cwd);
|
|
94
|
+
setter(`little-coder · ${label}`);
|
|
75
95
|
}
|
|
76
96
|
|
|
77
97
|
export default function (pi: ExtensionAPI) {
|
|
@@ -82,6 +102,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
82
102
|
// points (interactive-mode.js:1179, 1346, 3971), so re-setting on every
|
|
83
103
|
// turn keeps our "little-coder - <cwd>" winning for the duration of a
|
|
84
104
|
// session.
|
|
105
|
+
const reassertTitle = (ctx: { hasUI: boolean; cwd: string; ui: { setTitle: (t: string) => void } }) => {
|
|
106
|
+
if (!ctx.hasUI) return;
|
|
107
|
+
setTitle(ctx.ui.setTitle.bind(ctx.ui), ctx.cwd, safeGetSessionName(pi));
|
|
108
|
+
};
|
|
109
|
+
|
|
85
110
|
pi.on("session_start", async (_event, ctx) => {
|
|
86
111
|
if (!ctx.hasUI) return;
|
|
87
112
|
|
|
@@ -92,16 +117,37 @@ export default function (pi: ExtensionAPI) {
|
|
|
92
117
|
invalidate() {},
|
|
93
118
|
}));
|
|
94
119
|
|
|
95
|
-
|
|
120
|
+
reassertTitle(ctx);
|
|
96
121
|
});
|
|
97
122
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
123
|
+
// Auto-name an as-yet-unnamed session after the user's first real prompt, so
|
|
124
|
+
// it's identifiable in `/resume` and the tab title without anyone running
|
|
125
|
+
// `/name`. Only genuine interactive typing names a session — never the
|
|
126
|
+
// benchmark RPC path or programmatic follow-ups (thinking-budget nudges,
|
|
127
|
+
// plan-mode synthesis). `/name` still overrides at any time.
|
|
128
|
+
pi.on("input", async (event, ctx) => {
|
|
129
|
+
if ((event as any).source !== "interactive") return;
|
|
130
|
+
if (safeGetSessionName(pi)) return; // already named (auto or via /name)
|
|
131
|
+
const name = deriveSessionName(String((event as any).text ?? ""));
|
|
132
|
+
if (!name) return;
|
|
133
|
+
try {
|
|
134
|
+
pi.setSessionName(name);
|
|
135
|
+
} catch {
|
|
136
|
+
// older SDK without setSessionName — title still falls back to cwd
|
|
137
|
+
}
|
|
138
|
+
reassertTitle(ctx);
|
|
101
139
|
});
|
|
102
140
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
141
|
+
// Pi calls updateTerminalTitle() at turn boundaries (interactive-mode.js),
|
|
142
|
+
// which would clobber ours back to "π - <cwd>"; re-assert at the same points.
|
|
143
|
+
pi.on("turn_start", async (_event, ctx) => reassertTitle(ctx));
|
|
144
|
+
pi.on("turn_end", async (_event, ctx) => reassertTitle(ctx));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function safeGetSessionName(pi: ExtensionAPI): string | undefined {
|
|
148
|
+
try {
|
|
149
|
+
return typeof pi.getSessionName === "function" ? pi.getSessionName() : undefined;
|
|
150
|
+
} catch {
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
107
153
|
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { glob as fsGlob } from "node:fs/promises";
|
|
2
2
|
|
|
3
|
-
// Bounded file globbing. The naive `for await (…glob…) { if (len>=
|
|
3
|
+
// Bounded file globbing. The naive `for await (…glob…) { if (len>=100) break }`
|
|
4
4
|
// only caps MATCHES — it does nothing about the WALK. Run from a huge root
|
|
5
5
|
// (e.g. a home directory with macOS Library / caches / node_modules), fs.glob
|
|
6
6
|
// recursively descends everything, and its internal traversal state grows until
|
|
7
|
-
// the Node process OOMs (heap, not the model's context) — long before
|
|
7
|
+
// the Node process OOMs (heap, not the model's context) — long before 100
|
|
8
8
|
// matches are found if matches are sparse. fs.glob exposes no signal/abort and
|
|
9
9
|
// no depth/scan cap, so we bound it through the one hook it does call for every
|
|
10
10
|
// entry: `exclude`. We use it to (a) prune heavy/irrelevant directories so they
|
|
@@ -45,7 +45,7 @@ export interface GlobOutcome {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
export const DEFAULT_MAX_SCAN = 200_000;
|
|
48
|
-
export const DEFAULT_MAX_MATCHES =
|
|
48
|
+
export const DEFAULT_MAX_MATCHES = 100;
|
|
49
49
|
|
|
50
50
|
export async function globFiles(pattern: string, opts: GlobOptions): Promise<GlobOutcome> {
|
|
51
51
|
const maxScan = opts.maxScan ?? DEFAULT_MAX_SCAN;
|
|
@@ -10,7 +10,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
10
10
|
name: "glob",
|
|
11
11
|
label: "Glob",
|
|
12
12
|
description:
|
|
13
|
-
"Find files matching a glob pattern. Returns a sorted list of matching paths (up to
|
|
13
|
+
"Find files matching a glob pattern. Returns a sorted list of matching paths (up to 100). " +
|
|
14
14
|
"Common dependency/build/cache dirs (node_modules, .git, dist, …) are skipped, and the walk " +
|
|
15
15
|
"is bounded — for a focused search, pass a `path` rather than globbing a whole home directory.",
|
|
16
16
|
parameters: Type.Object({
|
|
@@ -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
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
)
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
+
}
|