smolcoder 0.4.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/LICENSE +21 -0
- package/README.md +197 -0
- package/dist/agent.js +411 -0
- package/dist/context.js +337 -0
- package/dist/detect.js +199 -0
- package/dist/events.js +24 -0
- package/dist/index.js +680 -0
- package/dist/plan.js +91 -0
- package/dist/prompt.js +85 -0
- package/dist/providers/lmstudio.js +326 -0
- package/dist/providers/ollama.js +264 -0
- package/dist/providers/types.js +60 -0
- package/dist/sandbox.js +179 -0
- package/dist/tools/check.js +193 -0
- package/dist/tools/fs-tools.js +417 -0
- package/dist/tools/index.js +236 -0
- package/dist/tools/shell.js +168 -0
- package/dist/tools/tasks.js +128 -0
- package/dist/tui/editor.js +134 -0
- package/dist/tui/keys.js +145 -0
- package/dist/tui/tui.js +632 -0
- package/dist/ui.js +226 -0
- package/dist/util.js +72 -0
- package/dist/web/page.js +381 -0
- package/dist/web/webui.js +262 -0
- package/package.json +48 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Leon van Zyl
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# smolcoder
|
|
2
|
+
|
|
3
|
+
A tiny, zero-config CLI coding agent for **local models**. Ollama and LM Studio only — and because it supports only those, it can make them first-class: no base URLs, no API keys, no config files, no setup questions. Start it and code.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install -g smolcoder
|
|
7
|
+
smol
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Works on Windows, macOS, and Linux. Zero runtime dependencies.
|
|
11
|
+
|
|
12
|
+
## The TUI
|
|
13
|
+
|
|
14
|
+
smol opens straight into a session — the last model you used (or the first one detected) is picked automatically. Everything is changed in-session:
|
|
15
|
+
|
|
16
|
+
- **`/` slash commands** with an autocomplete menu: `/models` (switch model, type to filter), `/mode`, `/effort`, `/tasks`, `/compact`, …
|
|
17
|
+
- **shift+tab** cycles read-only → edit → bypass permissions
|
|
18
|
+
- **esc** cancels a running turn; **ctrl+c ×2** quits
|
|
19
|
+
- The status line under the input shows mode · model · effort · context fill · running background tasks, live.
|
|
20
|
+
- `/effort` maps to Ollama's `think` parameter and LM Studio's `reasoning_effort`. `off` disables thinking on qwen3-class models — a real speedup. Models that don't support it fall back silently.
|
|
21
|
+
|
|
22
|
+
For scripts and automations there is a headless mode that prints the transcript and exits: `smol -p "prompt"` (reasoning noise suppressed, exit code reflects success).
|
|
23
|
+
|
|
24
|
+
## Why
|
|
25
|
+
|
|
26
|
+
Most coding harnesses treat local models as an afterthought: you configure endpoints by hand, and then they inject huge system prompts, dozens of tools, MCP servers and skills into a model with a small context window. smolcoder is built the other way around:
|
|
27
|
+
|
|
28
|
+
- **Zero config.** Probes the standard Ollama (`127.0.0.1:11434`, or `$OLLAMA_HOST`) and LM Studio (`127.0.0.1:1234`) endpoints and lists whatever models you already have. Docker-hosted Ollama with the usual port mapping is picked up automatically.
|
|
29
|
+
- **Tiny context footprint.** A two-paragraph system prompt, exactly seven flat tools, hard caps on every tool output, and no MCP, no skills, no subagents.
|
|
30
|
+
- **Context windows handled properly.** For Ollama, smolcoder respects the server's own configured context length (the Ollama app's setting) — it preloads the model and reads the effective window from `/api/ps`, sending an explicit `num_ctx` only on old Ollama versions where the silent tiny default would truncate prompts, or when you pass `--ctx`. For LM Studio it reads the loaded context length from `/api/v0/models` and budgets within it. Real token usage reported by the backend drives a live context meter and automatic compaction (old tool output is evicted first — nearly free — and the conversation is summarized only when that's not enough).
|
|
31
|
+
- **Small-model-friendly tools.** Flat string parameters, an example call in every description, and error messages written as coaching (a failed edit shows the closest real snippet to copy). The edit tool forgives whitespace drift — the difference between usable and unusable local editing.
|
|
32
|
+
|
|
33
|
+
## Modes
|
|
34
|
+
|
|
35
|
+
| Mode | Files | Commands |
|
|
36
|
+
|------|-------|----------|
|
|
37
|
+
| `ro` (read-only) | read/search only | none |
|
|
38
|
+
| `edit` (default) | read/write/edit | runs freely inside the workspace (`npm install`, tests, scripts); a command that reaches outside it asks y/n (or **a**lways-allow that program for the session) |
|
|
39
|
+
| `bypass` (bypass permissions) | read/write/edit | never asks for approval |
|
|
40
|
+
|
|
41
|
+
The mode decides which tools *exist* — in read-only mode the model is never even told a write tool exists. File tools are sandboxed to the workspace folder (symlink escapes included). Commands run with the workspace as their working directory, and in edit mode the command text is scanned before it runs: absolute paths outside the workspace, `/tmp`, `~`, temp-dir variables, `..` climbing past the root, and global package installs all trigger the approval prompt (with the reason shown). This is a best-effort text scan, not an OS sandbox — a command can still reach outside through, say, a script it runs — so use read-only mode for untrusted work and bypass only when you want no prompts at all.
|
|
42
|
+
|
|
43
|
+
## The plan — a compass for small models
|
|
44
|
+
|
|
45
|
+
For multi-step tasks the agent keeps a to-do list via a `plan` tool (`set` / `done` / `add` / `show`). It's not a gimmick copied from the big harnesses — it's built for small context windows:
|
|
46
|
+
|
|
47
|
+
- The list lives in the **harness**, not in a file or the transcript, so rendering it costs zero tokens and **compaction can never destroy it** — after every compaction the checklist is re-injected, so the model wakes up looking at its map.
|
|
48
|
+
- Every `done` result answers "what's next" in ~10 tokens, continuously re-focusing the model.
|
|
49
|
+
- If the model tries to stop with steps unfinished, the harness pushes back once — attacking the classic local-model failure of quitting halfway.
|
|
50
|
+
- You see it live: a checklist block in the TUI whenever it changes, `plan 2/4` in the status bar, `/plan` to reprint it, and checklist updates on stderr in headless runs.
|
|
51
|
+
|
|
52
|
+
## AGENTS.md memory
|
|
53
|
+
|
|
54
|
+
If the workspace contains an `AGENTS.md`, its contents are injected right after the system prompt (size-capped at ~2k tokens) and survive compaction. Put your project conventions, commands, and quirks there.
|
|
55
|
+
|
|
56
|
+
## Tools the model gets
|
|
57
|
+
|
|
58
|
+
`read_file` · `write_file` · `edit_file` · `list_files` · `search` · `plan` · `run_command` · `task`
|
|
59
|
+
|
|
60
|
+
`task` manages background processes (dev servers, watchers): `start`, `list`, `logs`, `stop`. Background tasks are non-blocking, keep a ring buffer of recent output, show up in the status line, and are killed when smolcoder exits. You can inspect them yourself with `/tasks`, `/logs <id>`, `/stop <id>`.
|
|
61
|
+
|
|
62
|
+
## Usage
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
smol # current folder, remembers your last model & mode
|
|
66
|
+
smol path/to/project # a specific workspace
|
|
67
|
+
smol --mode bypass # bypass permissions: no approval prompts
|
|
68
|
+
smol --model qwen3 # pick a model by (partial) name
|
|
69
|
+
smol --ctx 16384 # cap the context window (Ollama: sets num_ctx)
|
|
70
|
+
smol -p "fix the failing test" # one-shot, non-interactive
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
In a session: `/mode`, `/model`, `/context`, `/compact`, `/tasks`, `/logs <id>`, `/stop <id>`, `/clear`, `/help`, `/exit`. `Ctrl+C` cancels a running turn.
|
|
74
|
+
|
|
75
|
+
## Backend notes: Ollama vs LM Studio
|
|
76
|
+
|
|
77
|
+
Measured head-to-head with identical qwen3.8-27B Q4_K_M weights on one RTX 5090
|
|
78
|
+
(the model is a hybrid recurrent `qwen35` build; Ollama 0.33, LM Studio 0.4.21):
|
|
79
|
+
|
|
80
|
+
| | Ollama | LM Studio |
|
|
81
|
+
|---|---|---|
|
|
82
|
+
| prompt processing (6.7k-token cold prompt) | ~3,300 tok/s | ~3,000–3,400 tok/s |
|
|
83
|
+
| generation, 400 tokens of prose, thinking off | ~120 tok/s | ~68 (4 slots + MTP) / ~76 (1 slot, no MTP) tok/s |
|
|
84
|
+
| generation, 1,200 tokens of JavaScript, thinking off | ~170 tok/s | ~110 (1 slot + MTP) / ~76 (no MTP) tok/s |
|
|
85
|
+
| one agent step (plan + write call), thinking off | 0.9 s | 1.5–1.8 s |
|
|
86
|
+
| one agent step with thinking, same prompt | 0.8–10 s (38–3,500 reasoning chars) | 1.8–85 s (250–19,000 reasoning chars) |
|
|
87
|
+
|
|
88
|
+
The engine is the same llama.cpp on both, so the differences come from what the
|
|
89
|
+
harness sends and from load settings:
|
|
90
|
+
|
|
91
|
+
- **Reasoning effort was the whole story behind "LM Studio is slow".** LM Studio's
|
|
92
|
+
API takes `reasoning_effort` none/minimal/low/medium/high/xhigh, but each model only
|
|
93
|
+
supports some of them and a value the model lacks is silently replaced by the
|
|
94
|
+
model's *default* — which for current qwen3.x builds is **xhigh**, the maximum.
|
|
95
|
+
Asking for `high` therefore produced 8,000-token thinking bursts before single
|
|
96
|
+
tool calls. smolcoder now reads the model's supported levels and default from
|
|
97
|
+
`/api/v1/models`, sends `none` for `off` (measured: fully disables thinking), and
|
|
98
|
+
snaps other levels to the nearest one the model has (`high` → `medium` on qwen;
|
|
99
|
+
ties go to the cheaper level). The status line shows the mapping (`high → medium`,
|
|
100
|
+
`default → xhigh`), and a warning is printed when the default is the maximum.
|
|
101
|
+
On Ollama, `off` is a real `think: false` and any other level is `think: true`
|
|
102
|
+
(levels only exist for gpt-oss there).
|
|
103
|
+
- **Thinking is unpredictable on local models.** The same prompt at the same level
|
|
104
|
+
thought for 250 characters one run and 19,000 the next. For long tool loops,
|
|
105
|
+
`--effort off` is the reliable setting on both backends; `low`/`medium` are fine
|
|
106
|
+
for questions and planning.
|
|
107
|
+
- **Generation is ~1.5× faster on Ollama** with default settings (Ollama also
|
|
108
|
+
drafts 4 tokens per step with the model's MTP head; LM Studio drafts 2). On LM
|
|
109
|
+
Studio, keep MTP speculative decoding ON for coding — it took code generation from
|
|
110
|
+
76 to ~110 tok/s (it slightly slows prose, which is what most benchmarks measure) —
|
|
111
|
+
and load with a single slot, which is another ~10%:
|
|
112
|
+
`lms load <model> --context-length 65536 --parallel 1 --speculative-draft-mtp`.
|
|
113
|
+
Prompt caching works on both (only the new tail of the prompt is processed).
|
|
114
|
+
- **Ollama keeps the model resident** for 30 minutes between calls (its own default
|
|
115
|
+
unloads after 5 min — a long approval pause used to cost a 10–20 s reload).
|
|
116
|
+
Override with `SMOLCODER_KEEP_ALIVE=1h`.
|
|
117
|
+
- **Reasoning traces are not replayed** for finished turns (the qwen templates drop
|
|
118
|
+
them anyway); only the current turn's traces travel with the tool loop. On a
|
|
119
|
+
thinking model this is the largest single prompt-size saving.
|
|
120
|
+
|
|
121
|
+
## End-to-end: the same Minecraft build on both backends
|
|
122
|
+
|
|
123
|
+
One headless run each (`smol -p "<prompt>" --mode bypass --effort off`), same
|
|
124
|
+
model weights, same prompt (procedural voxel terrain, first-person controls, block
|
|
125
|
+
place/remove, three.js from a CDN, then serve it). Nobody typed "continue".
|
|
126
|
+
|
|
127
|
+
| | Ollama | LM Studio (1 slot, MTP on) |
|
|
128
|
+
|---|---|---|
|
|
129
|
+
| wall clock | 64 s | 85 s |
|
|
130
|
+
| tool calls | 20 (5 files) | 14 (3 files) |
|
|
131
|
+
| tokens generated | 9.4k @ 171 tok/s | 7.4k @ 96 tok/s |
|
|
132
|
+
| plan | 6/6 done | 3/3 done |
|
|
133
|
+
| syntax warnings from the write hook | 0 | 0 |
|
|
134
|
+
| result in the browser | loads, no console errors; one mesh-winding bug | loads, no console errors; terrain not visible |
|
|
135
|
+
|
|
136
|
+
Both agents finished on their own, checked their files with `node --check`, and
|
|
137
|
+
started a static server to prove the page served. Reproduce with `bench/run.sh`
|
|
138
|
+
(the prompt is `bench/minecraft-prompt.txt`; every headless run ends with a
|
|
139
|
+
`[stats]` JSON line on stderr). Both first drafts had one real bug
|
|
140
|
+
(this is a 27B model writing a voxel engine with thinking off); each was fixed with
|
|
141
|
+
a second headless turn carrying a one-paragraph, symptom-only bug report:
|
|
142
|
+
|
|
143
|
+
| fix turn | Ollama | LM Studio |
|
|
144
|
+
|---|---|---|
|
|
145
|
+
| wall clock | 42 s | 100 s |
|
|
146
|
+
| tool calls | 27 (reads, searches, 3 edits, node one-liners) | 27 (7 reads, 3 edits, 10 commands, 6 plan) |
|
|
147
|
+
| tokens generated | 4.8k @ 165 tok/s | 6.9k @ 79 tok/s |
|
|
148
|
+
| outcome | solid terrain; face vertex order fixed | terrain renders; spawn height + camera pitch fixed |
|
|
149
|
+
|
|
150
|
+
### The same build with reasoning on (`--effort high`)
|
|
151
|
+
|
|
152
|
+
`high` resolves to `medium` on this LM Studio model (its levels are off/low/medium/xhigh;
|
|
153
|
+
ties snap to the cheaper neighbour) and to `think: true` on Ollama.
|
|
154
|
+
|
|
155
|
+
| | Ollama (`think: true`) | LM Studio (`high → medium`) |
|
|
156
|
+
|---|---|---|
|
|
157
|
+
| wall clock | 84 s | 289 s (about a third of it a port-collision detour caused by the test setup) |
|
|
158
|
+
| tool calls | 16 | 28 |
|
|
159
|
+
| tokens generated | 10.6k @ 146 tok/s, ~2.6k of them reasoning | 22.5k @ 84 tok/s, ~12.2k of them reasoning |
|
|
160
|
+
| plan | 4/4 | 5/5 |
|
|
161
|
+
| result in the browser | correct on the first try: terrain, hills, controls, no console errors | correct on the first try: terrain, hills, controls, no console errors |
|
|
162
|
+
|
|
163
|
+
Reasoning bought correctness: both first drafts worked, where both effort-off drafts had
|
|
164
|
+
needed a bug-fix turn. The price is time — the model's per-step thinking is where the
|
|
165
|
+
backends differ most (Ollama's `think: true` produced a quarter as many reasoning
|
|
166
|
+
tokens as LM Studio's `medium`), and LM Studio's default `xhigh` would have been far
|
|
167
|
+
slower still. Note that the two effort-off runs plus their fix turns (106 s on Ollama,
|
|
168
|
+
185 s on LM Studio) still beat the reasoning runs on wall clock.
|
|
169
|
+
|
|
170
|
+
### Forcing compaction
|
|
171
|
+
|
|
172
|
+
The same build with the window capped at 12k tokens (`--ctx 12000`, output budget
|
|
173
|
+
3k) is the stress test for everything above: Ollama finished the whole game in
|
|
174
|
+
104 s and 28 tool calls with context management kicking in five times, the plan
|
|
175
|
+
reported 5/5, and the page rendered. On LM Studio the same run went through a tier-1
|
|
176
|
+
eviction (7.4k → 5.1k tokens) and then a real tier-2 compaction (7.5k → 2.1k tokens:
|
|
177
|
+
system prompt + plan + model-written hand-over notes + the working tail), after which
|
|
178
|
+
the model carried on with the remaining steps — syntax check, serve, summarize. (That
|
|
179
|
+
LM Studio build parses and serves but throws a runtime TypeError on load — a file
|
|
180
|
+
assembled in five pieces under a 3k output cap is where a 27B model starts to slip,
|
|
181
|
+
and a parse check cannot catch runtime errors.) Two things made the runs work at all:
|
|
182
|
+
|
|
183
|
+
- when a whole-file `write_file` overflows the output cap, the model is told the call
|
|
184
|
+
was **not executed** and coached to write the file in parts (first part with
|
|
185
|
+
`write_file`, then `edit_file` appends) — before that coaching existed the model
|
|
186
|
+
retried the same oversized write three times in a row;
|
|
187
|
+
- the plan and the hand-over notes are re-injected after every compaction, so the
|
|
188
|
+
model resumes at the right step instead of starting over.
|
|
189
|
+
|
|
190
|
+
## Requirements
|
|
191
|
+
|
|
192
|
+
- Node.js 18+
|
|
193
|
+
- [Ollama](https://ollama.com) with at least one tool-capable model pulled (e.g. `ollama pull qwen3`), **or** LM Studio with its local server running (Developer tab → Start Server).
|
|
194
|
+
|
|
195
|
+
## License
|
|
196
|
+
|
|
197
|
+
[MIT](LICENSE)
|
package/dist/agent.js
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// The agent loop: one tool call at a time, tool results fed back, until the
|
|
3
|
+
// model answers in plain text. Parallel tool calls are not requested; if a
|
|
4
|
+
// model emits several anyway, they simply run sequentially. Malformed calls
|
|
5
|
+
// come back as coaching errors so the model can retry instead of derailing.
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.Agent = void 0;
|
|
8
|
+
exports.isAutoApproved = isAutoApproved;
|
|
9
|
+
exports.describeStats = describeStats;
|
|
10
|
+
const index_1 = require("./tools/index");
|
|
11
|
+
const util_1 = require("./util");
|
|
12
|
+
const sandbox_1 = require("./sandbox");
|
|
13
|
+
const TRANSIENT_ERROR = /fetch failed|econn|socket|network|timed?.?out|50[0234]/i;
|
|
14
|
+
/** Shell metacharacters that let one "allowed program" smuggle in others.
|
|
15
|
+
* Auto-approval via always-allow only applies to commands without them. */
|
|
16
|
+
const SHELL_META = /[;&|`$<>(){}\n\r\\]/;
|
|
17
|
+
/** Exported for tests: does the always-allow set cover this exact command?
|
|
18
|
+
* First-token match alone is bypassable (`npm -v; evil`) because commands run
|
|
19
|
+
* under a real shell — so chained/piped/substituted commands always re-prompt. */
|
|
20
|
+
function isAutoApproved(command, allowed) {
|
|
21
|
+
const trimmed = command.trim();
|
|
22
|
+
let program = trimmed.split(/\s+/)[0] ?? "";
|
|
23
|
+
if (process.platform === "win32")
|
|
24
|
+
program = program.toLowerCase();
|
|
25
|
+
return allowed.has(program) && !SHELL_META.test(trimmed);
|
|
26
|
+
}
|
|
27
|
+
class Agent {
|
|
28
|
+
provider;
|
|
29
|
+
mode;
|
|
30
|
+
systemPrompt;
|
|
31
|
+
toolCtx;
|
|
32
|
+
ctxMgr;
|
|
33
|
+
bus;
|
|
34
|
+
ui;
|
|
35
|
+
interactive;
|
|
36
|
+
maxSteps;
|
|
37
|
+
messages = [];
|
|
38
|
+
tools;
|
|
39
|
+
alwaysAllowed = new Set();
|
|
40
|
+
originalRequest = "";
|
|
41
|
+
currentRequest = "";
|
|
42
|
+
planNudged = false;
|
|
43
|
+
abort = null;
|
|
44
|
+
/** Speed/size figures for the last completed turn (for the turn-end label
|
|
45
|
+
* and headless stats). */
|
|
46
|
+
lastTurnStats = null;
|
|
47
|
+
constructor(provider, mode, systemPrompt, toolCtx, ctxMgr, bus, ui, interactive,
|
|
48
|
+
/** Tool-call budget per user turn. Headless runs get a much larger one. */
|
|
49
|
+
maxSteps = 30) {
|
|
50
|
+
this.provider = provider;
|
|
51
|
+
this.mode = mode;
|
|
52
|
+
this.systemPrompt = systemPrompt;
|
|
53
|
+
this.toolCtx = toolCtx;
|
|
54
|
+
this.ctxMgr = ctxMgr;
|
|
55
|
+
this.bus = bus;
|
|
56
|
+
this.ui = ui;
|
|
57
|
+
this.interactive = interactive;
|
|
58
|
+
this.maxSteps = maxSteps;
|
|
59
|
+
this.messages = [{ role: "system", content: systemPrompt }];
|
|
60
|
+
this.tools = (0, index_1.buildToolSpecs)(mode);
|
|
61
|
+
}
|
|
62
|
+
setMode(mode, systemPrompt) {
|
|
63
|
+
this.mode = mode;
|
|
64
|
+
this.tools = (0, index_1.buildToolSpecs)(mode);
|
|
65
|
+
this.messages[0] = { role: "system", content: systemPrompt };
|
|
66
|
+
}
|
|
67
|
+
setProvider(provider) {
|
|
68
|
+
this.provider = provider;
|
|
69
|
+
}
|
|
70
|
+
resetTranscript() {
|
|
71
|
+
this.messages = [this.messages[0]];
|
|
72
|
+
this.originalRequest = "";
|
|
73
|
+
this.currentRequest = "";
|
|
74
|
+
this.planNudged = false;
|
|
75
|
+
// Session facts feed the compaction state note — stale ones from a
|
|
76
|
+
// cleared conversation would assert work the new task never did.
|
|
77
|
+
this.toolCtx.filesTouched.clear();
|
|
78
|
+
this.toolCtx.commandsRun.length = 0;
|
|
79
|
+
this.ctxMgr.resetAnchor();
|
|
80
|
+
}
|
|
81
|
+
cancel() {
|
|
82
|
+
this.abort?.abort();
|
|
83
|
+
}
|
|
84
|
+
contextPercent() {
|
|
85
|
+
return this.ctxMgr.fillPercent(this.messages, this.tools);
|
|
86
|
+
}
|
|
87
|
+
contextTokens() {
|
|
88
|
+
return this.ctxMgr.estimatePrompt(this.messages, this.tools);
|
|
89
|
+
}
|
|
90
|
+
async compactNow() {
|
|
91
|
+
await this.bus.emit("pre_compact");
|
|
92
|
+
const { messages, report } = await this.ctxMgr.manage(this.messages, this.tools, this.provider, {
|
|
93
|
+
originalRequest: this.originalRequest,
|
|
94
|
+
currentRequest: this.currentRequest,
|
|
95
|
+
filesTouched: this.toolCtx.filesTouched,
|
|
96
|
+
commandsRun: this.toolCtx.commandsRun,
|
|
97
|
+
planLine: this.toolCtx.plan.compactLine(),
|
|
98
|
+
});
|
|
99
|
+
this.messages = messages;
|
|
100
|
+
await this.bus.emit("post_compact", report);
|
|
101
|
+
}
|
|
102
|
+
async runTurn(userInput) {
|
|
103
|
+
if (!this.originalRequest)
|
|
104
|
+
this.originalRequest = userInput;
|
|
105
|
+
this.currentRequest = userInput; // the task compaction must never lose
|
|
106
|
+
this.messages.push({ role: "user", content: userInput });
|
|
107
|
+
this.abort = new AbortController();
|
|
108
|
+
const signal = this.abort.signal;
|
|
109
|
+
const t0 = Date.now();
|
|
110
|
+
let completed = false;
|
|
111
|
+
let steps = 0;
|
|
112
|
+
let nudges = 0;
|
|
113
|
+
let toolCallsThisTurn = 0;
|
|
114
|
+
let sincePlanUpdate = 0;
|
|
115
|
+
const stats = {
|
|
116
|
+
modelCalls: 0,
|
|
117
|
+
toolCalls: 0,
|
|
118
|
+
generatedTokens: 0,
|
|
119
|
+
genSeconds: 0,
|
|
120
|
+
thinkingChars: 0,
|
|
121
|
+
promptTokensLast: 0,
|
|
122
|
+
durationMs: 0,
|
|
123
|
+
};
|
|
124
|
+
this.lastTurnStats = stats;
|
|
125
|
+
try {
|
|
126
|
+
while (steps++ < this.maxSteps) {
|
|
127
|
+
// Context management before every request.
|
|
128
|
+
await this.bus.emit("pre_request");
|
|
129
|
+
if (this.ctxMgr.needsAttention(this.messages, this.tools)) {
|
|
130
|
+
this.ui.status("· context is getting full — compacting…");
|
|
131
|
+
await this.compactNow();
|
|
132
|
+
}
|
|
133
|
+
this.ui.startSpinner("thinking");
|
|
134
|
+
let result;
|
|
135
|
+
try {
|
|
136
|
+
result = await this.chatWithRetry(signal);
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
this.ui.stopSpinner();
|
|
140
|
+
}
|
|
141
|
+
this.messages.push({
|
|
142
|
+
role: "assistant",
|
|
143
|
+
content: result.content,
|
|
144
|
+
toolCalls: result.toolCalls.length ? result.toolCalls : undefined,
|
|
145
|
+
thinking: result.thinking,
|
|
146
|
+
});
|
|
147
|
+
// Anchor AFTER the push: lastPromptTokens+lastCompletionTokens then
|
|
148
|
+
// cover exactly the first `messages.length` messages — recording
|
|
149
|
+
// before the push double-counted the reply in every estimate.
|
|
150
|
+
this.ctxMgr.recordUsage(result.promptTokens, result.completionTokens, this.messages.length);
|
|
151
|
+
stats.modelCalls++;
|
|
152
|
+
if (result.generatedTokens) {
|
|
153
|
+
stats.generatedTokens += result.generatedTokens;
|
|
154
|
+
if (result.genTokPerSec)
|
|
155
|
+
stats.genSeconds += result.generatedTokens / result.genTokPerSec;
|
|
156
|
+
}
|
|
157
|
+
if (result.promptTokens)
|
|
158
|
+
stats.promptTokensLast = result.promptTokens;
|
|
159
|
+
if (result.thinking)
|
|
160
|
+
stats.thinkingChars += result.thinking.length;
|
|
161
|
+
if (result.content)
|
|
162
|
+
this.ui.println(); // end the streamed line
|
|
163
|
+
if (result.toolCalls.length === 0) {
|
|
164
|
+
// A reply cut off by the output cap, or an empty reply, is not a
|
|
165
|
+
// finished turn — that is how local-model sessions die silently.
|
|
166
|
+
// Nudge the model back on track (bounded).
|
|
167
|
+
if (result.truncated && nudges < 3) {
|
|
168
|
+
nudges++;
|
|
169
|
+
this.ui.status("· reply hit the output limit — asking the model to continue");
|
|
170
|
+
// Reasoning models can burn the ENTIRE budget thinking, arriving
|
|
171
|
+
// with no visible output at all — "continue where you left off"
|
|
172
|
+
// would just restart the same doomed think. Target that case.
|
|
173
|
+
// With thinking off, an empty truncated reply is almost always a
|
|
174
|
+
// tool call whose arguments (a whole file) overflowed the cap — it
|
|
175
|
+
// was never parsed, so nothing was saved and "continue" cannot
|
|
176
|
+
// work. Name the cap and coach the split explicitly.
|
|
177
|
+
const noContent = !result.content.trim();
|
|
178
|
+
const burnedByThinking = noContent && !!result.thinking?.trim();
|
|
179
|
+
const nudgeText = burnedByThinking
|
|
180
|
+
? `[Your reasoning used the entire output limit (${this.provider.maxOutputTokens} tokens) and produced no answer. Do not re-derive everything — reply now with your next tool call or a brief answer.]`
|
|
181
|
+
: noContent
|
|
182
|
+
? `[${this.truncatedCallHint()}]`
|
|
183
|
+
: `[Your reply was cut off by the output length limit of ${this.provider.maxOutputTokens} tokens. Continue where you left off. If a file was too large for one write_file call, split the content into separate files — writing the same path again replaces it completely.]`;
|
|
184
|
+
this.messages.push({ role: "user", content: nudgeText });
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (!result.content.trim() && nudges < 2) {
|
|
188
|
+
nudges++;
|
|
189
|
+
this.ui.status("· empty reply — nudging the model");
|
|
190
|
+
this.messages.push({
|
|
191
|
+
role: "user",
|
|
192
|
+
content: "[Your reply was empty. If the task is finished, summarize what you did. Otherwise make the next tool call now.]",
|
|
193
|
+
});
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
// The model wants to stop but its own plan still has open steps —
|
|
197
|
+
// the classic local-model quit-halfway. One bounded push back.
|
|
198
|
+
// One nudge PER PLAN STATE, not per turn: an abandoned plan must not
|
|
199
|
+
// drag every later unrelated question back to stale work. The flag
|
|
200
|
+
// re-arms only when the plan actually changes (set/done/add).
|
|
201
|
+
const plan = this.toolCtx.plan;
|
|
202
|
+
if (plan.exists && plan.currentIndex >= 0 && toolCallsThisTurn > 0 && !this.planNudged) {
|
|
203
|
+
this.planNudged = true;
|
|
204
|
+
this.ui.status("· plan has unfinished steps — nudging the model to continue");
|
|
205
|
+
this.messages.push({
|
|
206
|
+
role: "user",
|
|
207
|
+
content: `[Your plan still has unfinished steps: ${plan.pendingSummary()}. Continue with the next step now — or if a step no longer applies, mark it done with the plan tool and explain why.]`,
|
|
208
|
+
});
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
completed = true;
|
|
212
|
+
return; // plain answer — turn over
|
|
213
|
+
}
|
|
214
|
+
nudges = 0;
|
|
215
|
+
for (const call of result.toolCalls) {
|
|
216
|
+
if (signal.aborted)
|
|
217
|
+
throw abortError();
|
|
218
|
+
this.ui.toolCall(call.name, call.parseError ? { __raw: (call.rawArgs ?? "").slice(0, 80) } : call.args);
|
|
219
|
+
let output;
|
|
220
|
+
if (call.parseError) {
|
|
221
|
+
// LM Studio streams the partial arguments of a cut-off call, so
|
|
222
|
+
// the overflow surfaces here as unparseable JSON.
|
|
223
|
+
output = result.truncated
|
|
224
|
+
? `Error: ${this.truncatedCallHint()}`
|
|
225
|
+
: `Error: your tool call arguments could not be parsed (${call.parseError}). Send the arguments as a single JSON object, e.g. {"path": "src/app.js"}.`;
|
|
226
|
+
}
|
|
227
|
+
else if (!this.tools.some((t) => t.name === call.name)) {
|
|
228
|
+
// HARD mode enforcement. The schemas sent to the model are only
|
|
229
|
+
// advisory — a hallucinated or injected write_file/run_command in
|
|
230
|
+
// read-only mode must be rejected here, at execution time.
|
|
231
|
+
output = `Error: the tool "${call.name}" is not available in ${index_1.MODE_LABELS[this.mode]} mode. Available tools: ${this.tools.map((t) => t.name).join(", ")}.`;
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
output = await this.gateAndExecute(call.name, call.args, signal);
|
|
235
|
+
toolCallsThisTurn++;
|
|
236
|
+
stats.toolCalls++;
|
|
237
|
+
// Tier-0 context hygiene: a full overwrite makes every earlier
|
|
238
|
+
// read of that file wrong. Stub them out right away.
|
|
239
|
+
if (call.name === "write_file" && !output.startsWith("Error") && typeof call.args?.path === "string") {
|
|
240
|
+
this.ctxMgr.evictStaleReads(this.messages, call.args.path);
|
|
241
|
+
}
|
|
242
|
+
// Keep the plan honest: small models forget to mark steps done
|
|
243
|
+
// mid-flow, leaving the checklist stale for minutes. A periodic
|
|
244
|
+
// one-line reminder riding on a tool result fixes it cheaply.
|
|
245
|
+
const plan = this.toolCtx.plan;
|
|
246
|
+
if (call.name === "plan") {
|
|
247
|
+
sincePlanUpdate = 0;
|
|
248
|
+
if (!output.startsWith("Error"))
|
|
249
|
+
this.planNudged = false; // plan changed — re-arm
|
|
250
|
+
}
|
|
251
|
+
else if (plan.exists && plan.currentIndex >= 0 && ++sincePlanUpdate >= 4) {
|
|
252
|
+
sincePlanUpdate = 0;
|
|
253
|
+
const cur = plan.steps[plan.currentIndex];
|
|
254
|
+
output += `\n[Reminder: the plan still shows step ${plan.currentIndex + 1} "${cur.text}" as current. If you have finished steps, mark each with plan {"action": "done"} now.]`;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
// Plan changes render as the visual checklist instead of a ✓ line.
|
|
258
|
+
if (call.name === "plan" &&
|
|
259
|
+
!output.startsWith("Error") &&
|
|
260
|
+
["set", "done", "add"].includes(String(call.args?.action))) {
|
|
261
|
+
this.ui.planUpdated(this.toolCtx.plan);
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
this.ui.toolResult(output);
|
|
265
|
+
}
|
|
266
|
+
this.messages.push({
|
|
267
|
+
role: "tool",
|
|
268
|
+
content: output,
|
|
269
|
+
toolCallId: call.id,
|
|
270
|
+
toolName: call.name,
|
|
271
|
+
});
|
|
272
|
+
await this.bus.emit("post_tool", { name: call.name, args: call.args });
|
|
273
|
+
// A cancel during tool execution ends the turn now, with the
|
|
274
|
+
// (cancelled) result already recorded so the transcript stays valid.
|
|
275
|
+
if (signal.aborted)
|
|
276
|
+
throw abortError();
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
this.ui.warn(`Stopped after ${this.maxSteps} tool calls in one turn. Say "continue" to keep going.`);
|
|
280
|
+
completed = true;
|
|
281
|
+
}
|
|
282
|
+
catch (err) {
|
|
283
|
+
if (err?.name === "AbortError" || signal.aborted) {
|
|
284
|
+
this.ui.println();
|
|
285
|
+
this.ui.status("· cancelled");
|
|
286
|
+
this.sanitizeAfterCancel();
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
throw err;
|
|
290
|
+
}
|
|
291
|
+
finally {
|
|
292
|
+
this.abort = null;
|
|
293
|
+
stats.durationMs = Date.now() - t0;
|
|
294
|
+
if (completed) {
|
|
295
|
+
this.ui.turnEnd(`${index_1.MODE_LABELS[this.mode]} · ${this.provider.modelId} · ${(0, util_1.fmtDuration)(stats.durationMs)}${describeStats(stats)}`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
/** Coaching for a tool call that overflowed the output cap. Exported via
|
|
300
|
+
* the class for tests. */
|
|
301
|
+
truncatedCallHint() {
|
|
302
|
+
const cap = this.provider.maxOutputTokens;
|
|
303
|
+
const part = Math.max(300, Math.floor(cap * 0.5));
|
|
304
|
+
return (`Your tool call was cut off by the output limit of ${cap} tokens, so it was NOT executed and nothing was saved. ` +
|
|
305
|
+
`Send smaller calls: write the file in parts of at most ~${part} tokens — write_file with the first part, ` +
|
|
306
|
+
`then edit_file to append each next part (old_text = the last line you wrote, new_text = that line followed by the next part) — ` +
|
|
307
|
+
`or split the code across several smaller files.`);
|
|
308
|
+
}
|
|
309
|
+
/** One model call, with bounded retries on transient backend failures
|
|
310
|
+
* (Ollama/LM Studio hiccups, dropped sockets, 5xx). The transcript is
|
|
311
|
+
* unchanged between attempts, so a retry is always safe. */
|
|
312
|
+
async chatWithRetry(signal) {
|
|
313
|
+
let lastErr;
|
|
314
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
315
|
+
try {
|
|
316
|
+
return await this.provider.chat(this.messages, this.tools, {
|
|
317
|
+
signal,
|
|
318
|
+
onToken: (t) => this.ui.token(t),
|
|
319
|
+
onThinking: (t) => this.ui.thinking(t),
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
catch (err) {
|
|
323
|
+
if (err?.name === "AbortError" || signal.aborted)
|
|
324
|
+
throw err;
|
|
325
|
+
lastErr = err;
|
|
326
|
+
if (attempt === 3 || !TRANSIENT_ERROR.test(String(err?.message ?? err)))
|
|
327
|
+
throw err;
|
|
328
|
+
this.ui.warn(`· backend error (${String(err?.message ?? err).slice(0, 80)}) — retrying in ${attempt * 3}s`);
|
|
329
|
+
await new Promise((r) => setTimeout(r, attempt * 3000));
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
throw lastErr;
|
|
333
|
+
}
|
|
334
|
+
async gateAndExecute(name, args, signal) {
|
|
335
|
+
const command = (0, index_1.commandOf)(name, args);
|
|
336
|
+
// Gate everywhere except bypass (defense-in-depth: in ro mode exec tools are
|
|
337
|
+
// already rejected before this point by the tool-existence check). Edit
|
|
338
|
+
// mode runs commands that stay inside the workspace without asking and
|
|
339
|
+
// only prompts for ones that reach outside it.
|
|
340
|
+
if (command !== null && this.mode !== "bypass") {
|
|
341
|
+
const reason = (0, sandbox_1.commandEscapesWorkspace)(command, this.toolCtx.workspace);
|
|
342
|
+
if (reason !== null && !isAutoApproved(command, this.alwaysAllowed)) {
|
|
343
|
+
if (!this.interactive) {
|
|
344
|
+
return `Error: this command ${reason}, which needs user approval, and this session is non-interactive. Keep every path inside the workspace (relative paths, a scratch folder in the workspace instead of /tmp), or the user can rerun smol with --mode bypass, or run this themselves: ${command}`;
|
|
345
|
+
}
|
|
346
|
+
const answer = await this.ui.confirmCommand(command, reason);
|
|
347
|
+
if (answer === "no") {
|
|
348
|
+
return "The user declined to run this command. Continue without it, or ask the user what to do instead.";
|
|
349
|
+
}
|
|
350
|
+
if (answer === "always") {
|
|
351
|
+
let program = command.trim().split(/\s+/)[0] ?? "";
|
|
352
|
+
if (process.platform === "win32")
|
|
353
|
+
program = program.toLowerCase();
|
|
354
|
+
if (program)
|
|
355
|
+
this.alwaysAllowed.add(program);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return (0, index_1.executeTool)(name, args, this.toolCtx, signal);
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* After a cancel, the most recent assistant tool-call message may have some
|
|
363
|
+
* calls unanswered — strict backends reject that shape on the next request.
|
|
364
|
+
* A cancel mid-way through a MULTI-call batch buries that assistant message
|
|
365
|
+
* behind the already-pushed tool results, so walk back past them.
|
|
366
|
+
*/
|
|
367
|
+
sanitizeAfterCancel() {
|
|
368
|
+
let i = this.messages.length - 1;
|
|
369
|
+
while (i >= 0 && this.messages[i].role === "tool")
|
|
370
|
+
i--;
|
|
371
|
+
const anchor = this.messages[i];
|
|
372
|
+
if (anchor?.role === "assistant" && anchor.toolCalls?.length) {
|
|
373
|
+
for (const tc of anchor.toolCalls) {
|
|
374
|
+
const answered = this.messages.some((m) => m.role === "tool" && m.toolCallId === tc.id);
|
|
375
|
+
if (!answered) {
|
|
376
|
+
this.messages.push({
|
|
377
|
+
role: "tool",
|
|
378
|
+
content: "[cancelled by the user before this tool ran]",
|
|
379
|
+
toolCallId: tc.id,
|
|
380
|
+
toolName: tc.name,
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
statusLine() {
|
|
387
|
+
const pct = this.contextPercent();
|
|
388
|
+
const tasks = this.toolCtx.taskManager.runningSummary();
|
|
389
|
+
const taskPart = tasks.length ? ` · ${tasks.length} bg task${tasks.length > 1 ? "s" : ""}` : "";
|
|
390
|
+
return util_1.c.gray(`ctx ${pct}% of ${this.provider.contextWindow.toLocaleString()} · ${this.provider.label} · ${this.mode}${taskPart}`);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
exports.Agent = Agent;
|
|
394
|
+
/** " · 12 tools · 4.1k tok @ 118 tok/s" — the speed readout local-model users
|
|
395
|
+
* actually want to compare backends with. */
|
|
396
|
+
function describeStats(s) {
|
|
397
|
+
const parts = [];
|
|
398
|
+
if (s.toolCalls)
|
|
399
|
+
parts.push(`${s.toolCalls} tool${s.toolCalls === 1 ? "" : "s"}`);
|
|
400
|
+
if (s.generatedTokens) {
|
|
401
|
+
const k = s.generatedTokens >= 1000 ? `${(s.generatedTokens / 1000).toFixed(1)}k` : String(s.generatedTokens);
|
|
402
|
+
const rate = s.genSeconds > 0 ? ` @ ${Math.round(s.generatedTokens / s.genSeconds)} tok/s` : "";
|
|
403
|
+
parts.push(`${k} tok${rate}`);
|
|
404
|
+
}
|
|
405
|
+
return parts.length ? " · " + parts.join(" · ") : "";
|
|
406
|
+
}
|
|
407
|
+
function abortError() {
|
|
408
|
+
const e = new Error("aborted");
|
|
409
|
+
e.name = "AbortError";
|
|
410
|
+
return e;
|
|
411
|
+
}
|