kronk-cli 0.3.1 → 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/README.md CHANGED
@@ -595,6 +595,7 @@ same way this run did.
595
595
  | `-a`, `--auto` | off | Autonomous: auto-approve tools **and** run until the task is done. Implies `--yes` |
596
596
  | `-y`, `--yes` | off | Auto-approve `write_file` and `bash` without the autonomous prompt |
597
597
  | `--no-think` | off | Disable the model's reasoning pass server-side. Much faster |
598
+ | `--no-subagents` | off | Remove the `task` tool, so the model cannot delegate — see [Sub-agents](#sub-agents) |
598
599
  | `--steps <n>` | unlimited | Cap tool calls per task. `0`, `off`, `none`, `inf`, `unlimited` all mean no cap |
599
600
  | `-h`, `--help` | — | Print all options and exit |
600
601
  | `--` | — | End option parsing; everything after it is prompt text, dashes and all |
@@ -633,12 +634,13 @@ The per-turn usage line still prints after each response; this one is the runnin
633
634
  |---|---|
634
635
  | `/help` | list these commands |
635
636
  | `/models` | what Kronk is serving, with sizes and what is resident |
636
- | `/model <id>` | switch model — substring match, `/AGENT` preferred |
637
+ | `/model <id>` | switch model — substring match, `/AGENT` preferred; re-reads that model's window and caps |
637
638
  | `/file <path>` | add a file to the conversation as context |
638
639
  | `/auto` | toggle autonomous mode (auto-approve + run to completion) |
639
640
  | `/steps [n\|off]` | show or set the tool-call cap |
640
641
  | `/thinking` | show or hide the model's reasoning |
641
642
  | `/think` | turn reasoning off entirely — much faster |
643
+ | `/agents` | the sub-agents `task` can delegate to, and what each may touch |
642
644
  | `/mcp` | list attached MCP servers and their tools |
643
645
  | `/context` | how much of the context window is used |
644
646
  | `/compact` | replace the conversation with a summary of itself |
@@ -655,7 +657,7 @@ The per-turn usage line still prints after each response; this one is the runnin
655
657
  | `KRONK_TOKEN` | `kronk` | Any non-empty value while Kronk runs open; a real JWT when protected |
656
658
  | `KRONK_MODEL` | `unsloth/Qwen3.6-35B-A3B-UD-Q4_K_M/AGENT` | Model id |
657
659
  | `KRONK_MODEL_CONFIG` | `~/.kronk/models/model_config.yaml` | Kronk's per-model config, the file `kronk-cli setup` writes |
658
- | `KRONK_MAX_TOKENS` | `8192` | Output cap per response |
660
+ | `KRONK_MAX_TOKENS` | the model's profile | Output cap per response. Unset, the selected model's own `max_tokens` decides — `8192` only when its profile sets none |
659
661
  | `KRONK_MAX_STEPS` | unlimited | Cap on tool calls per task |
660
662
  | `KRONK_THINKING` | `true` | `false` hides reasoning but still generates it |
661
663
  | `KRONK_NO_THINK` | — | `1` disables reasoning server-side |
@@ -668,6 +670,9 @@ The per-turn usage line still prints after each response; this one is the runnin
668
670
  | `KRONK_CONFIG` | `~/.kronk-cli.json` | Path to the config file, so tests and throwaway runs stay off the real one |
669
671
  | `KRONK_DISTILL` | `true` | `false` disables tool-output distillation |
670
672
  | `KRONK_DISTILL_AT` | `8000` | Characters of output that trigger distillation |
673
+ | `KRONK_SUBAGENTS` | `true` | `false` removes the `task` tool |
674
+ | `KRONK_SUBAGENT_MODEL` | the main model | Model sub-agents run on — substring match, same as `KRONK_MODEL` |
675
+ | `KRONK_SUBAGENT_STEPS` | `40` | Tool-call cap for one delegated task |
671
676
  | `KRONK_WARM` | `true` | `false` skips the boot-time model preload |
672
677
  | `KRONK_AUTO_COMPACT` | `true` | `false` disables automatic compaction |
673
678
  | `KRONK_COMPACT_AT` | `0.85` | Fraction of the window that triggers compaction |
@@ -690,6 +695,8 @@ The per-turn usage line still prints after each response; this one is the runnin
690
695
  "noThink": true,
691
696
  "preserveThinking": true,
692
697
  "replayReasoning": true,
698
+ "subagentModel": "Qwen3.6-4B",
699
+ "subagentSteps": 40,
693
700
  "sandboxReadable": ["~/Library/Keychains"]
694
701
  }
695
702
  ```
@@ -851,6 +858,13 @@ not read `reasoning_content` either and would discard the blocks.
851
858
  Kronk reports the **effective** window for whichever model id you selected — that comes from
852
859
  `context-window` in `model_config.yaml`, so a `/AGENT` profile and its base model can differ.
853
860
 
861
+ Every limit is read from the selected model's profile, and re-read when the selection changes:
862
+ the window, the output cap (`sampling-parameters.max_tokens`), whether the chat template
863
+ understands `preserve_thinking`, and the model's native maximum. `/model` mid-session moves all
864
+ of them, and a sub-agent running on `KRONK_SUBAGENT_MODEL` compacts against *its* window rather
865
+ than the main model's. The one number that is not taken from the profile is a `KRONK_MAX_TOKENS`
866
+ you set yourself — yours always wins.
867
+
854
868
  Three places surface it:
855
869
 
856
870
  **The banner**
@@ -962,10 +976,15 @@ Disable with `--no-compact` or `KRONK_AUTO_COMPACT=false` if you would rather se
962
976
  | `write_file` | ✋ | Create or overwrite; shows a diff preview first |
963
977
  | `bash` | ✋ | Run a command; shows it first |
964
978
  | `set_plan` | — | Record the task checklist; replaces the stored one |
979
+ | `task` | — | Delegate one job to a [sub-agent](#sub-agents) with its own context |
965
980
 
966
981
  `set_plan` writes nothing and runs nothing — it hands the harness a list, which is why it is
967
982
  never gated. See [Autonomous mode](#autonomous-mode) for what the harness then does with it.
968
983
 
984
+ `task` is not gated either, because it cannot do anything by itself: every write and every
985
+ command a sub-agent asks for comes back to the same prompt you would have seen had the main
986
+ agent asked for it.
987
+
969
988
  `--yes` and `--auto` skip the prompts. Paths resolve against the session directory and cannot
970
989
  escape the launch root — including through a symlink. `bash` additionally runs under an OS
971
990
  sandbox where one is available; see [Scope](#scope) for exactly what that covers. `bash` keeps its working directory **between calls**, so a bare `cd`
@@ -1166,6 +1185,127 @@ same way.
1166
1185
 
1167
1186
  ---
1168
1187
 
1188
+ ## Sub-agents
1189
+
1190
+ The model can hand one job to a **sub-agent**: a fresh agent with its own context window, its
1191
+ own tool loop, and no memory of your conversation. It runs the task, and the only thing that
1192
+ ever comes back is its report.
1193
+
1194
+ ```console
1195
+ › which tools in this repo need approval, and where is that decided?
1196
+
1197
+ 1 ⚙ task explore: list every tool definition in src/, say which are gated and where
1198
+ │ 1/40 ⚙ search /def\(/ in src
1199
+ │ ✓ 9 lines
1200
+ │ 2/40 ⚙ read src/tools.js
1201
+ │ ✓ 587 lines
1202
+ │ 3/40 ⚙ read src/subagent.js
1203
+ │ ✓ 211 lines
1204
+ │ 2104→380 tok · 58.1 tok/s · 1980 cached
1205
+ Six built-in tools. write_file and bash are gated, by NEEDS_APPROVAL at
1206
+ src/tools.js:261. MCP tools are gated by name, mcpNeedsApproval at :270.
1207
+ read_file, list_dir, search, set_plan and task are not gated.
1208
+ ✓ 3 lines
1209
+
1210
+ `write_file` and `bash`, from the `NEEDS_APPROVAL` set in src/tools.js:246…
1211
+ ```
1212
+
1213
+ `│` marks the sub-agent's own loop; the unprefixed lines are its report, which is what the main
1214
+ agent receives as the tool result.
1215
+
1216
+ Three files were read. None of them is in your conversation: the 800-odd lines they cost were
1217
+ spent in a context that no longer exists, and all that is left in your window is the four-line
1218
+ answer. That is the whole point, and it is the trade [distillation](#distillation) makes for
1219
+ command output, one level up.
1220
+
1221
+ Both agents run against the same Kronk server, so nothing leaves the machine that was not
1222
+ already leaving it.
1223
+
1224
+ ### It is not parallelism
1225
+
1226
+ Kronk holds one resident copy of the model, so two sub-agents at once queue behind each other
1227
+ and finish no sooner than two in sequence. Delegation here buys **context**, not wall-clock:
1228
+ give away the work that is expensive to read and cheap to conclude.
1229
+
1230
+ ### What it costs, measured
1231
+
1232
+ The same survey of `src/` — one table row per module, its responsibility and the gotcha its own
1233
+ comments call out, plus three facts quoted with `file:line` — run twice per model on this
1234
+ machine, once with `--no-subagents` and once told to orchestrate. `main ctx peak` is the largest
1235
+ prompt the *main* conversation ever sent, which is the number the feature exists to move:
1236
+
1237
+ | model | mode | wall | main ctx peak | tokens generated | answer |
1238
+ |---|---|---|---|---|---|
1239
+ | Qwen3.6-35B-A3B `/AGENT` | plain | 143s | 30.8k | 5.5k | 23/23 |
1240
+ | Qwen3.6-35B-A3B `/AGENT` | orchestrated, 5 sub-agents | 352s | **12.9k** | 16.2k | 22/23 |
1241
+ | Ornith-1.5-35B `/AGENT` | plain | 154s | 34.4k | 5.8k | 23/23 |
1242
+ | Ornith-1.5-35B `/AGENT` | orchestrated, 5 sub-agents | 357s | **5.9k** | 16.5k | 23/23 |
1243
+
1244
+ Delegation cut the main conversation's high-water mark by 58% and 83%, and cost roughly 2.4×
1245
+ the wall clock and 3× the generated tokens to do it. That is the trade in one line: **you are
1246
+ buying window, and paying for it in time and tokens.** It is worth it when the conversation has
1247
+ to keep going afterwards, and not worth it for a question you were going to ask once.
1248
+
1249
+ The one answer that got worse is worth reading too. Qwen's orchestrated run scored 22/23 because
1250
+ the sub-agent it asked for "the constant that caps a tool result" came back with a plausible
1251
+ wrong one from a different module, and the orchestrator had no way to know: it never read the
1252
+ file. The plain run made the same mistake, spent three rounds re-reading `tools.js`, and caught
1253
+ it. **A sub-agent's report is evidence you cannot cross-examine** — ask for `file:line` and
1254
+ verbatim quotes in the prompt, and check the ones that matter.
1255
+
1256
+ ### The two agents
1257
+
1258
+ | Agent | Tools | For |
1259
+ |---|---|---|
1260
+ | `explore` | `read_file`, `list_dir`, `search` | Reading, searching, tracing how something works |
1261
+ | `code` | those plus `write_file`, `bash` | Making a change, running a build, reproducing a failure |
1262
+
1263
+ `/agents` prints this in the REPL, with the model and step cap in force.
1264
+
1265
+ Two roles rather than a directory of them, because every extra one is a line the model has to
1266
+ read and choose between on each call, and a local model asked to pick between eight
1267
+ near-synonyms picks badly. The split that changes what can actually happen is whether it may
1268
+ touch anything, so that is the split.
1269
+
1270
+ ### What it cannot do
1271
+
1272
+ - **Ask you anything.** There is nobody in its loop. It is told to take the most reasonable
1273
+ reading of the task, act, and say what it assumed.
1274
+ - **Delegate further.** The `task` tool is only ever offered at the top level, which is the
1275
+ entire recursion guard.
1276
+ - **Get a command past you.** Its `write_file` and `bash` calls arrive at the same prompt the
1277
+ main agent's do, nested under the call that started them. `--yes` and `--auto` skip them
1278
+ exactly as they always did.
1279
+ - **Touch the checklist.** `set_plan` belongs to the task you asked for; a sub-agent has no
1280
+ access to it.
1281
+ - **Reach MCP servers.** Its tool list is deliberately the short one.
1282
+
1283
+ It also runs under a step cap — 40 by default, where the main agent's is unlimited — because
1284
+ nobody is watching it, and a report is worth less than the window a runaway loop would spend
1285
+ earning it.
1286
+
1287
+ ### Knobs
1288
+
1289
+ ```bash
1290
+ kronk-cli --no-subagents # remove the task tool entirely
1291
+ KRONK_SUBAGENT_MODEL=<id> kronk-cli # run the grunt work on a different model
1292
+ KRONK_SUBAGENT_STEPS=80 kronk-cli # a longer leash per task
1293
+ ```
1294
+
1295
+ A sub-agent gets the same startup scan of your project the main agent got, so it starts knowing
1296
+ what the repo is instead of spending its first two steps finding out.
1297
+
1298
+ `KRONK_SUBAGENT_MODEL` needs a model that can actually drive a tool loop, and "smaller" is not
1299
+ the same thing as "cheaper" here. Measured on the survey task below with `qwen2.5-coder-1.5b`
1300
+ as the sub-agent model: every sub-agent returned prose without calling a single tool, the main
1301
+ agent noticed, read all eighteen modules itself, and finished with **47.1k** of context against
1302
+ the 30.8k it uses when you never delegate at all. A sub-agent that cannot do the work costs you
1303
+ the delegation *and* the work.
1304
+
1305
+ > A small model will not delegate unprompted as often as a frontier one does. Asking for it
1306
+ > works — *"use a sub-agent to survey the test suite first"* — and is usually how you will
1307
+ > drive this.
1308
+
1169
1309
  ## Autonomous mode
1170
1310
 
1171
1311
  `--auto`, or `/auto` in the REPL, swaps in a system prompt that tells the model to finish the
@@ -1245,6 +1385,7 @@ previous prompt prefix — watch `cached` climb in the usage line.
1245
1385
  | `src/context.js` | startup scan: git, layout, `AGENTS.md` |
1246
1386
  | `src/compact.js` | summarizing the conversation when the window fills |
1247
1387
  | `src/plan.js` | the task checklist and the snapshot the model sees |
1388
+ | `src/subagent.js` | delegation: agent roles, their tools, the report that comes back |
1248
1389
  | `src/mcp.js` | MCP client: stdio + HTTP transports, tool routing |
1249
1390
  | `src/distill.js` | summarizing large tool output in a throwaway context |
1250
1391
  | `src/config.js` | precedence of flags, env, config file |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kronk-cli",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "A Claude-Code-style terminal agent for local models served by Kronk.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
package/src/agent.js CHANGED
@@ -11,6 +11,7 @@ import { maybeDistill } from './distill.js';
11
11
  import {
12
12
  carryChecklist, clearPlan, openItems, outstandingLines, planLines, pushNudge,
13
13
  } from './plan.js';
14
+ import { runTask, taskTools, TASK_TOOL } from './subagent.js';
14
15
 
15
16
  export const SYSTEM = `You are kronk-cli, a terse coding assistant running fully offline on the user's machine.
16
17
 
@@ -50,11 +51,11 @@ const thought = (reasoning) => (reasoning ? { reasoning_content: reasoning } : {
50
51
  * view as the wire request that just overflowed — see `compact` in
51
52
  * src/compact.js.
52
53
  */
53
- async function compactInto(messages, model, signal, auto) {
54
- const res = await compact(messages, { model, signal, auto });
55
- if (res.failed || res.skipped) { console.log(report(res)); return false; }
54
+ async function compactInto(messages, { out = console.log, ...opts }) {
55
+ const res = await compact(messages, opts);
56
+ if (res.failed || res.skipped) { out(report(res)); return false; }
56
57
  messages.splice(0, messages.length, ...res.messages);
57
- console.log(report(res));
58
+ out(report(res));
58
59
  return true;
59
60
  }
60
61
 
@@ -68,8 +69,8 @@ const MAX_NUDGES = 2;
68
69
  * message — the reply, or the step-cap note — so a nudge is never left sitting
69
70
  * directly before the next typed prompt.
70
71
  */
71
- function endTurn(messages) {
72
- outstandingLines().forEach((l) => console.log(l));
72
+ function endTurn(messages, { out = console.log, plan = true } = {}) {
73
+ if (plan) outstandingLines().forEach((l) => out(l));
73
74
  return messages;
74
75
  }
75
76
 
@@ -86,8 +87,14 @@ function endTurn(messages) {
86
87
  */
87
88
  export async function runTurn({
88
89
  messages, model, signal, approve, grant, mcp, auto = false, maxSteps = config.maxSteps,
90
+ tools: toolset = TOOLS, plan: usePlan = true, out = console.log, stream = true, depth = 0,
91
+ // The window and the output cap belong to `model`, not to the session: a
92
+ // sub-agent may be running on a different model with a different profile.
93
+ window = config.contextWindow, maxTokens = config.maxTokens,
89
94
  }) {
90
- const tools = mcp ? [...TOOLS, ...mcp.toolDefs()] : TOOLS;
95
+ // `taskTools` is empty below the top level, which is the whole recursion
96
+ // guard: a sub-agent is never handed the tool that spawns one.
97
+ const tools = [...toolset, ...taskTools(depth), ...(mcp ? mcp.toolDefs() : [])];
91
98
  let totalUsage = null;
92
99
  let step = 0;
93
100
  let compacted = false;
@@ -97,17 +104,22 @@ export async function runTurn({
97
104
  // a checklist the previous turn left in the transcript is history, it is
98
105
  // already in the prompt prefix, and rewriting history is what evicts the
99
106
  // prompt cache — see `carryChecklist`.
100
- clearPlan();
107
+ if (usePlan) clearPlan();
101
108
 
102
109
  for (;;) {
103
110
  step += 1;
104
111
  if (Number.isFinite(maxSteps) && step > maxSteps) {
105
- console.log(c.yellow(` ⛔ step cap reached (${maxSteps}). Stopping.`));
106
- console.log(c.grey(' raise it with --steps N, or /steps N in the REPL'));
107
- messages.push({ role: 'assistant', content: '(stopped: step cap reached)' });
108
- return endTurn(messages);
112
+ out(c.yellow(` ⛔ step cap reached (${maxSteps}). Stopping.`));
113
+ out(c.grey(depth
114
+ ? ' raise it with KRONK_SUBAGENT_STEPS'
115
+ : ' raise it with --steps N, or /steps N in the REPL'));
116
+ messages.push({ role: 'assistant',
117
+ content: depth
118
+ ? `(stopped: the sub-agent reached its step cap of ${maxSteps} before finishing)`
119
+ : '(stopped: step cap reached)' });
120
+ return endTurn(messages, { out, plan: usePlan });
109
121
  }
110
- let sp = spinner('thinking');
122
+ let sp = spinner(depth ? 'sub-agent' : 'thinking');
111
123
  let text = '';
112
124
  let reasoning = '';
113
125
  let calls = [];
@@ -122,7 +134,7 @@ export async function runTurn({
122
134
  messages: forRequest(messages, auto),
123
135
  tools,
124
136
  signal,
125
- maxTokens: config.maxTokens,
137
+ maxTokens,
126
138
  noThink: config.noThink,
127
139
  // Re-read every step: this has to hold for the tool-loop follow-ups too,
128
140
  // and `/think` can flip the answer between one turn and the next.
@@ -132,7 +144,9 @@ export async function runTurn({
132
144
  // Accumulated before the display check: whether the user watches the
133
145
  // model think has nothing to do with whether the model gets it back.
134
146
  reasoning += ev.value;
135
- if (!config.showThinking) continue;
147
+ // A sub-agent's reasoning is not the answer, and a second stream
148
+ // interleaved with the caller's is unreadable. See runTask.
149
+ if (!stream || !config.showThinking) continue;
136
150
  if (sp) { sp.stop(); sp = null; }
137
151
  if (!inReasoning) { process.stdout.write(c.grey('\n ┄ thinking ┄\n ')); inReasoning = true; }
138
152
  process.stdout.write(c.grey(ev.value.replace(/\n/g, '\n ')));
@@ -140,9 +154,10 @@ export async function runTurn({
140
154
  }
141
155
 
142
156
  else if (ev.type === 'text') {
157
+ text += ev.value;
158
+ if (!stream) continue;
143
159
  if (sp) { sp.stop(); sp = null; }
144
160
  if (inReasoning) { process.stdout.write(c.grey('\n ┄─────────┄\n\n')); inReasoning = false; }
145
- text += ev.value;
146
161
  process.stdout.write(ev.value);
147
162
  wroteAnything = true;
148
163
  }
@@ -159,13 +174,13 @@ export async function runTurn({
159
174
  if (sp) { sp.stop(); sp = null; }
160
175
  if (isOverflow(e) && !compacted) {
161
176
  compacted = true;
162
- console.log(c.yellow('\n context full — compacting and retrying'));
177
+ out(c.yellow('\n context full — compacting and retrying'));
163
178
  // The plan itself is module state, so compaction cannot reach it. The
164
179
  // snapshot it just summarised away is not put back: there is no tool
165
180
  // result left to carry one, and inserting a message here would be the
166
181
  // rewrite `carryChecklist` exists to avoid. The next round's tool
167
182
  // results carry the plan again.
168
- if (await compactInto(messages, model, signal, auto)) {
183
+ if (await compactInto(messages, { model, signal, auto, out, window, maxTokens })) {
169
184
  step -= 1;
170
185
  continue;
171
186
  }
@@ -178,15 +193,21 @@ export async function runTurn({
178
193
  if (inReasoning) process.stdout.write(c.grey('\n ┄─────────┄\n'));
179
194
  if (wroteAnything) process.stdout.write('\n');
180
195
  if (totalUsage) {
181
- console.log(fmtUsage(totalUsage, config.contextWindow));
182
- config.lastUsed = (totalUsage.prompt_tokens ?? 0) + (totalUsage.completion_tokens ?? 0);
196
+ out(fmtUsage(totalUsage, window));
197
+ // The status line reports the conversation the user is typing into. A
198
+ // sub-agent's window is its own and is thrown away with it.
199
+ if (!depth) {
200
+ config.lastUsed = (totalUsage.prompt_tokens ?? 0) + (totalUsage.completion_tokens ?? 0);
201
+ }
183
202
  }
184
203
 
185
- if (config.autoCompact && config.contextWindow && totalUsage) {
204
+ if (config.autoCompact && window && totalUsage) {
186
205
  const used = (totalUsage.prompt_tokens ?? 0) + (totalUsage.completion_tokens ?? 0);
187
- if (used / config.contextWindow >= config.compactAt) {
188
- console.log(c.yellow(` context ${Math.round((used / config.contextWindow) * 100)}% full — compacting`));
189
- if (!await compactInto(messages, model, signal, auto)) config.autoCompact = false;
206
+ if (used / window >= config.compactAt) {
207
+ out(c.yellow(` context ${Math.round((used / window) * 100)}% full — compacting`));
208
+ if (!await compactInto(messages, { model, signal, auto, out, window, maxTokens })) {
209
+ config.autoCompact = false;
210
+ }
190
211
  }
191
212
  }
192
213
 
@@ -195,7 +216,7 @@ export async function runTurn({
195
216
  if (!text.trim()) {
196
217
  // Reasoning models sometimes spend the whole budget thinking and emit no
197
218
  // answer. Say so rather than returning silence.
198
- console.log(c.yellow(' (model produced no answer — raise KRONK_MAX_TOKENS or /thinking off)'));
219
+ out(c.yellow(' (model produced no answer — raise KRONK_MAX_TOKENS or /thinking off)'));
199
220
  messages.push({ role: 'assistant', content: '(no answer produced)', ...thought(reasoning) });
200
221
  } else {
201
222
  messages.push({ role: 'assistant', content: text, ...thought(reasoning) });
@@ -205,13 +226,13 @@ export async function runTurn({
205
226
  // keep going — but only when nobody is watching, and only twice: past
206
227
  // that the model is not going to finish, and looping is worse than
207
228
  // reporting what is left.
208
- if (auto && openItems().length && nudges < MAX_NUDGES) {
229
+ if (usePlan && auto && openItems().length && nudges < MAX_NUDGES) {
209
230
  nudges += 1;
210
231
  pushNudge(messages);
211
- console.log(c.yellow(` ⚠ ${openItems().length} checklist items still open — continuing`));
232
+ out(c.yellow(` ⚠ ${openItems().length} checklist items still open — continuing`));
212
233
  continue;
213
234
  }
214
- return endTurn(messages);
235
+ return endTurn(messages, { out, plan: usePlan });
215
236
  }
216
237
 
217
238
  messages.push({
@@ -239,17 +260,21 @@ export async function runTurn({
239
260
 
240
261
  const at = Number.isFinite(maxSteps) ? `${step}/${maxSteps}` : `${step}`;
241
262
  const isMcp = Boolean(mcp?.has(call.name));
263
+ // The same list the model was offered, not just the name it used: with
264
+ // delegation off, or below the top level, a `task` call is a call to a
265
+ // tool that does not exist and falls through to runTool saying so.
266
+ const isTask = !isMcp && call.name === 'task' && tools.includes(TASK_TOOL);
242
267
  const label = isMcp
243
268
  ? `${c.magenta('⚙')} ${call.name} ${c.grey(JSON.stringify(args).slice(0, 120))}`
244
269
  : `${c.blue(`⚙ ${describe(call.name, args)}`)}`;
245
- console.log(`${c.grey(` ${at}`)} ${label}`);
270
+ out(`${c.grey(` ${at}`)} ${label}`);
246
271
 
247
272
  if (isMcp ? mcpNeedsApproval(call.name) : NEEDS_APPROVAL.has(call.name)) {
248
273
  const body = preview(call.name, args);
249
- if (body) console.log(body.split('\n').map((l) => ` ${l}`).join('\n'));
274
+ if (body) out(body.split('\n').map((l) => ` ${l}`).join('\n'));
250
275
  const ok = await approve(call.name, args);
251
276
  if (!ok) {
252
- console.log(c.red(' ✗ denied'));
277
+ out(c.red(' ✗ denied'));
253
278
  messages.push({ role: 'tool', tool_call_id: call.id,
254
279
  content: 'error: the user denied this action. Ask what to do instead.' });
255
280
  continue;
@@ -276,11 +301,11 @@ export async function runTurn({
276
301
  if (answer === 'yes' || answer === 'always') {
277
302
  try {
278
303
  const wrote = rememberGrants(needed, { persist: answer === 'always' });
279
- if (wrote) console.log(c.grey(` remembered in ${wrote}`));
304
+ if (wrote) out(c.grey(` remembered in ${wrote}`));
280
305
  } catch (e) {
281
306
  // Failing to persist must not fail the command. The grant is
282
307
  // already in force for this session either way.
283
- console.log(c.yellow(` ${e.message}`));
308
+ out(c.yellow(` ${e.message}`));
284
309
  }
285
310
  } else {
286
311
  declineGrants(needed);
@@ -288,35 +313,51 @@ export async function runTurn({
288
313
  }
289
314
  }
290
315
 
291
- const live = liveLine();
292
316
  let result;
293
- try {
294
- result = isMcp
295
- ? await mcp.call(call.name, args)
296
- : await runTool(call.name, args, {
297
- onProgress: (info) => live.update({ ...info, window: config.contextWindow }),
298
- });
299
- } finally {
300
- live.done();
317
+ if (isTask) {
318
+ // No `liveLine` around this one. It rewrites the current line every
319
+ // 120 ms, which would erase the sub-agent's own output as fast as it
320
+ // is printed.
321
+ try {
322
+ result = await runTask(args, { run: runTurn, model, signal, approve, grant, out });
323
+ } catch (e) {
324
+ // Errors are data here exactly as they are in `runTool`: a
325
+ // sub-agent that died does not take the turn that delegated to it
326
+ // down with it.
327
+ result = `error: the sub-agent failed — ${e.message}`;
328
+ }
329
+ } else {
330
+ const live = liveLine();
331
+ try {
332
+ result = isMcp
333
+ ? await mcp.call(call.name, args)
334
+ : await runTool(call.name, args, {
335
+ onProgress: (info) => live.update({ ...info, window }),
336
+ });
337
+ } finally {
338
+ live.done();
339
+ }
301
340
  }
302
341
 
303
342
  // set_plan hands back a rendering of what the harness just stored; paying a
304
343
  // second model call to paraphrase our own text would be pure waste.
305
- if (call.name !== 'set_plan') {
344
+ // A sub-agent's report is already the distillation of everything it
345
+ // read; paying a second model call to summarise a summary is waste.
346
+ if (call.name !== 'set_plan' && !isTask) {
306
347
  result = await maybeDistill(result, {
307
- model, signal, command: args.cmd ?? describe(call.name, args),
348
+ model, signal, out, command: args.cmd ?? describe(call.name, args),
308
349
  });
309
350
  }
310
351
  const failed = result.startsWith('error:');
311
- if (failed) toolResultLines(result).forEach((l) => console.log(l));
312
- else if (call.name === 'set_plan') planLines().forEach((l) => console.log(l));
313
- else console.log(c.grey(` ✓ ${result.split('\n').length} lines`));
352
+ if (failed) toolResultLines(result).forEach((l) => out(l));
353
+ else if (call.name === 'set_plan') planLines().forEach((l) => out(l));
354
+ else out(c.grey(` ✓ ${result.split('\n').length} lines`));
314
355
  messages.push({ role: 'tool', tool_call_id: call.id, content: result });
315
356
  }
316
357
 
317
358
  // On the last tool result rather than in a message of its own, so the
318
359
  // round only ever adds to the prompt. Nothing already sent is touched.
319
- carryChecklist(messages);
360
+ if (usePlan) carryChecklist(messages);
320
361
  // loop: the model now sees the tool output
321
362
  }
322
363
  }
package/src/argv.js CHANGED
@@ -24,6 +24,7 @@ const SPECS = [
24
24
  { names: ['--no-compact'], kind: 'flag', key: 'noCompact' },
25
25
  { names: ['--no-warm'], kind: 'flag', key: 'noWarm' },
26
26
  { names: ['--no-think'], kind: 'flag', key: 'noThink' },
27
+ { names: ['--no-subagents'], kind: 'flag', key: 'noSubagents' },
27
28
  { names: ['-a', '--auto'], kind: 'flag', key: 'auto' },
28
29
  { names: ['-y', '--yes'], kind: 'flag', key: 'yes' },
29
30
  { names: ['--dry-run'], kind: 'flag', key: 'dryRun' },
@@ -102,6 +103,7 @@ export function parseArgv(argv) {
102
103
  error: null,
103
104
  help: false, models: false, mcpList: false,
104
105
  noContext: false, noCompact: false, noWarm: false, noThink: false,
106
+ noSubagents: false,
105
107
  auto: false, yes: false, dryRun: false,
106
108
  model: null,
107
109
  context: null,
package/src/client.js CHANGED
@@ -75,10 +75,41 @@ export function samplingOverride(metadata, sampling) {
75
75
  return diffs.length ? diffs : null;
76
76
  }
77
77
 
78
+ export const NO_LIMITS = {
79
+ configured: null, native: null, preserveThinking: false, samplingDiff: null, maxTokens: null,
80
+ };
81
+
82
+ /**
83
+ * Everything about one model that the rest of the program is allowed to
84
+ * assume, read out of that model's own profile — pure, so the whole surface
85
+ * is testable without a server.
86
+ *
87
+ * `maxTokens` is the profile's own output cap. Kronk reports 0 for a model
88
+ * whose profile does not set one, which is "no opinion" and not a limit of
89
+ * zero, so it comes back null and the caller keeps its default. Anything else
90
+ * is what the person who wrote the profile decided this model may generate,
91
+ * and it is not ours to halve.
92
+ */
93
+ export function parseLimits(d) {
94
+ if (!d) return NO_LIMITS;
95
+ const sampling = d.model_config?.['sampling-parameters'];
96
+ const profileMax = num(sampling?.max_tokens);
97
+ const nativeKey = Object.keys(d.metadata ?? {}).find((k) => k.endsWith('.context_length'));
98
+ const template = d.metadata?.['tokenizer.chat_template'];
99
+ return {
100
+ configured: d.model_config?.['context-window'] ?? null,
101
+ native: nativeKey ? Number(d.metadata[nativeKey]) : null,
102
+ preserveThinking: typeof template === 'string' && template.includes('preserve_thinking'),
103
+ samplingDiff: samplingOverride(d.metadata, sampling),
104
+ maxTokens: profileMax && profileMax > 0 ? profileMax : null,
105
+ };
106
+ }
107
+
78
108
  /**
79
- * Effective context window for a model id, the model's native maximum,
80
- * whether its chat template understands `preserve_thinking`, and whether the
81
- * profile is overriding the model's own sampling values.
109
+ * Effective context window for a model id, the model's native maximum, the
110
+ * output cap its profile sets, whether its chat template understands
111
+ * `preserve_thinking`, and whether the profile is overriding the model's own
112
+ * sampling values.
82
113
  * The id contains slashes, so it must be percent-encoded — Kronk's route takes
83
114
  * one path segment and 404s on a raw id.
84
115
  *
@@ -93,20 +124,9 @@ export function samplingOverride(metadata, sampling) {
93
124
  */
94
125
  export async function modelLimits(id) {
95
126
  try {
96
- const d = await (await req(`/kronk/models/${encodeURIComponent(id)}`)).json();
97
- const configured = d.model_config?.['context-window'] ?? null;
98
- const nativeKey = Object.keys(d.metadata ?? {}).find((k) => k.endsWith('.context_length'));
99
- const native = nativeKey ? Number(d.metadata[nativeKey]) : null;
100
- const template = d.metadata?.['tokenizer.chat_template'];
101
- const preserveThinking = typeof template === 'string' && template.includes('preserve_thinking');
102
- const samplingDiff = samplingOverride(d.metadata, d.model_config?.['sampling-parameters']);
103
- return {
104
- configured, native, preserveThinking, samplingDiff,
105
- };
127
+ return parseLimits(await (await req(`/kronk/models/${encodeURIComponent(id)}`)).json());
106
128
  } catch {
107
- return {
108
- configured: null, native: null, preserveThinking: false, samplingDiff: null,
109
- };
129
+ return NO_LIMITS;
110
130
  }
111
131
  }
112
132
 
package/src/compact.js CHANGED
@@ -73,16 +73,19 @@ function transcript(messages) {
73
73
  * reasoning-replay view of the current task as the wire request that
74
74
  * triggered it, not a second, independently-computed answer.
75
75
  */
76
- export async function compact(messages, { model, signal, auto = false } = {}) {
76
+ export async function compact(messages, {
77
+ model, signal, auto = false, window = config.contextWindow, maxTokens = config.maxTokens,
78
+ } = {}) {
77
79
  const system = messages[0];
78
80
  const raw = transcript(forRequest(messages, auto));
79
81
  if (!raw.trim()) return { messages, before: 0, after: 0 };
80
82
 
81
83
  const before = await tokenize(model, [system.content, raw].join('\n'));
82
84
 
83
- // Leave room for the instruction and the summary itself.
84
- const window = config.contextWindow ?? 32768;
85
- const { text: body, elided } = fit(raw, Math.floor(window * 0.6));
85
+ // Leave room for the instruction and the summary itself. The window belongs
86
+ // to the model this compaction runs on, which is not always the one the
87
+ // session started with — see applyLimits in src/config.js.
88
+ const { text: body, elided } = fit(raw, Math.floor((window ?? 32768) * 0.6));
86
89
  if (elided) console.log(c.grey(` transcript too large for one pass — elided ${elided.toLocaleString()} chars from the middle`));
87
90
 
88
91
  let summary = '';
@@ -92,7 +95,7 @@ export async function compact(messages, { model, signal, auto = false } = {}) {
92
95
  { role: 'user', content: `${body}\n\n---\n\n${PROMPT}` },
93
96
  ],
94
97
  signal,
95
- maxTokens: Math.min(4096, config.maxTokens),
98
+ maxTokens: Math.min(4096, maxTokens),
96
99
  noThink: true,
97
100
  })) {
98
101
  if (ev.type === 'text') summary += ev.value;
package/src/config.js CHANGED
@@ -14,6 +14,11 @@ function fileConfig() {
14
14
 
15
15
  const file = fileConfig();
16
16
 
17
+ /** Only ever used when neither the user nor the model's profile has a number. */
18
+ export const DEFAULT_MAX_TOKENS = 8192;
19
+
20
+ const maxTokensRaw = process.env.KRONK_MAX_TOKENS ?? file.maxTokens;
21
+
17
22
  /** Used when nothing is passed on the command line or in the environment. */
18
23
  export const DEFAULT_MODEL = 'unsloth/Qwen3.6-35B-A3B-UD-Q4_K_M/AGENT';
19
24
 
@@ -28,7 +33,13 @@ export const config = {
28
33
  baseUrl: process.env.KRONK_URL ?? file.baseUrl ?? 'http://localhost:11435/v1',
29
34
  token: process.env.KRONK_TOKEN ?? file.token ?? 'kronk',
30
35
  model: process.env.KRONK_MODEL ?? file.model ?? null, // null → DEFAULT_MODEL, then auto-pick
31
- maxTokens: Number(process.env.KRONK_MAX_TOKENS ?? file.maxTokens ?? 8192),
36
+ maxTokens: Number(maxTokensRaw ?? DEFAULT_MAX_TOKENS),
37
+ // Whether the number above is the user's answer or ours. When it is ours,
38
+ // boot replaces it with whatever the chosen model's own /AGENT profile
39
+ // allows — see applyLimits. Without this flag there is no way to tell a
40
+ // deliberate 8192 from the default one, and the profile would silently
41
+ // overrule a number the user typed.
42
+ maxTokensExplicit: maxTokensRaw !== undefined,
32
43
  // Unlimited by default — a run stops when the model is done or you press Ctrl-C.
33
44
  // Set --steps / KRONK_MAX_STEPS to opt into a cap.
34
45
  maxSteps: Number(process.env.KRONK_MAX_STEPS ?? file.maxSteps ?? Infinity),
@@ -56,6 +67,14 @@ export const config = {
56
67
  // never enters the conversation. Set KRONK_DISTILL=false to keep it whole.
57
68
  distill: (process.env.KRONK_DISTILL ?? String(file.distill ?? 'true')) !== 'false',
58
69
  distillAt: Number(process.env.KRONK_DISTILL_AT ?? file.distillAt ?? 8000),
70
+ // Delegation. A sub-agent runs one task in a context of its own and hands
71
+ // back only its report, so what it read never enters this conversation —
72
+ // src/subagent.js has the argument. The step cap is finite where the main
73
+ // one is not: nobody is watching a sub-agent, and its report is worth less
74
+ // than the window it would spend earning it.
75
+ subagents: (process.env.KRONK_SUBAGENTS ?? String(file.subagents ?? 'true')) !== 'false',
76
+ subagentModel: process.env.KRONK_SUBAGENT_MODEL ?? file.subagentModel ?? null,
77
+ subagentSteps: Number(process.env.KRONK_SUBAGENT_STEPS ?? file.subagentSteps ?? 40),
59
78
  // Kronk's per-model runtime settings. `setup` is the only thing that writes
60
79
  // it; the override exists so tests never go near the real one.
61
80
  modelConfigPath: process.env.KRONK_MODEL_CONFIG ?? file.modelConfigPath ?? MODEL_CONFIG,
@@ -66,13 +85,39 @@ export const config = {
66
85
  .filter((p) => typeof p === 'string' && p.trim())
67
86
  .map((p) => (p.startsWith('~/') ? join(homedir(), p.slice(2)) : p)),
68
87
  lastUsed: 0,
69
- contextWindow: null, // filled in at boot from Kronk
88
+ // The startup scan of the working directory, kept so a sub-agent starts
89
+ // knowing what the project is instead of spending its first two steps on it.
90
+ projectPrimer: null,
91
+ contextWindow: null, // filled in at boot from the chosen model's profile
70
92
  nativeContext: null,
93
+ // The sub-agent model's own limits, when one is configured. A sub-agent on
94
+ // a 32k model must not compact against the main model's 131k window.
95
+ subagentLimits: null,
71
96
  templatePreservesThinking: false, // filled in at boot from the model's template
72
97
  samplingOverride: null, // filled in at boot: params where the profile overrides the model's own
73
98
  rcPath: RC,
74
99
  };
75
100
 
101
+ /**
102
+ * Point the session's limits at one model's profile.
103
+ *
104
+ * Every one of these was resolved once at boot and then treated as a property
105
+ * of the program rather than of the model, so `/model` could move the session
106
+ * to a 32k model while compaction still aimed at 131k and `preserve_thinking`
107
+ * was still sent to a template that does not declare it. They are properties
108
+ * of whichever model is selected now.
109
+ */
110
+ export function applyLimits({
111
+ configured, native, preserveThinking, samplingDiff, maxTokens,
112
+ } = {}) {
113
+ config.contextWindow = configured ?? null;
114
+ config.nativeContext = native ?? null;
115
+ config.templatePreservesThinking = Boolean(preserveThinking);
116
+ config.samplingOverride = samplingDiff ?? null;
117
+ if (!config.maxTokensExplicit) config.maxTokens = maxTokens ?? DEFAULT_MAX_TOKENS;
118
+ return config;
119
+ }
120
+
76
121
  /**
77
122
  * Whether this request should pin the earlier think blocks: the user wants it,
78
123
  * the model's template declares the parameter, and reasoning is on at all —
package/src/distill.js CHANGED
@@ -103,10 +103,13 @@ export function shouldDistill(result) {
103
103
 
104
104
  export async function maybeDistill(result, opts) {
105
105
  if (!shouldDistill(result)) return result;
106
+ // `out` so a sub-agent's distillation prints inside its own indented block
107
+ // rather than at the caller's margin. See runTask in src/subagent.js.
108
+ const out = opts.out ?? console.log;
106
109
  const before = await tokenize(opts.model, result);
107
110
  const digest = await distill(result, opts);
108
111
  if (!digest || digest.length >= result.length) return result;
109
112
  const after = await tokenize(opts.model, digest);
110
- console.log(c.grey(` distilled ${before.toLocaleString()} → ${after.toLocaleString()} tokens (separate context)`));
113
+ out(c.grey(` distilled ${before.toLocaleString()} → ${after.toLocaleString()} tokens (separate context)`));
111
114
  return digest;
112
115
  }
package/src/index.js CHANGED
@@ -3,7 +3,7 @@ import readline from 'node:readline/promises';
3
3
  import { stdin, stdout } from 'node:process';
4
4
  import { homedir } from 'node:os';
5
5
  import { readFile } from 'node:fs/promises';
6
- import { config, DEFAULT_MODEL, warnIfInsecure } from './config.js';
6
+ import { config, DEFAULT_MODEL, applyLimits, warnIfInsecure } from './config.js';
7
7
  import { listModels, listModelDetails, listLoaded, modelLimits, tokenize } from './client.js';
8
8
  import { pickDefault, ensureLoaded } from './boot.js';
9
9
  import { runTurn, SYSTEM, SYSTEM_AUTO } from './agent.js';
@@ -14,6 +14,7 @@ import { compact, report } from './compact.js';
14
14
  import { loadServers, McpHub, reportFailures } from './mcp.js';
15
15
  import { resolveSandbox, sandbox } from './tools.js';
16
16
  import { parseArgv } from './argv.js';
17
+ import { AGENTS } from './subagent.js';
17
18
  import { runSetup } from './setup.js';
18
19
 
19
20
  // ---- argv -------------------------------------------------------------
@@ -50,6 +51,7 @@ if (args.help) {
50
51
  -a, --auto autonomous: approve tools automatically, finish the task
51
52
  -y, --yes approve tools automatically (no autonomous prompt)
52
53
  --no-think disable the model's reasoning pass (faster)
54
+ --no-subagents remove the task tool; no delegation
53
55
  --steps <n> cap tool calls per task (default: unlimited)
54
56
  -h, --help this message
55
57
  -- end option parsing; everything after is the prompt
@@ -59,7 +61,8 @@ if (args.help) {
59
61
  KRONK_TOKEN any non-empty value when Kronk runs open
60
62
  KRONK_MODEL overrides the default model
61
63
  KRONK_MODEL_CONFIG path to Kronk's model_config.yaml, used by setup
62
- KRONK_MAX_TOKENS output cap per response (default 8192)
64
+ KRONK_MAX_TOKENS output cap per response; overrides the cap the
65
+ chosen model's own profile sets (8192 if it sets none)
63
66
  KRONK_MAX_STEPS cap on tool calls per task (default unlimited)
64
67
  KRONK_NO_THINK set to 1 to disable reasoning
65
68
  KRONK_PRESERVE_THINKING
@@ -68,6 +71,11 @@ if (args.help) {
68
71
  KRONK_WARM false to skip the boot-time model preload
69
72
  KRONK_AUTO_COMPACT false to disable automatic compaction
70
73
  KRONK_COMPACT_AT fraction of the window that triggers it (default 0.85)
74
+ KRONK_SUBAGENTS false to remove the task tool
75
+ KRONK_SUBAGENT_MODEL
76
+ model sub-agents run on (default: the main model)
77
+ KRONK_SUBAGENT_STEPS
78
+ tool-call cap for one delegated task (default 40)
71
79
 
72
80
  Config file: ~/.kronk-cli.json
73
81
  `);
@@ -88,6 +96,7 @@ const MCP_ON = args.mcp;
88
96
  const MCP_WANTED = args.mcpNames;
89
97
 
90
98
  if (args.noThink) config.noThink = true;
99
+ if (args.noSubagents) config.subagents = false;
91
100
  if (args.noCompact) config.autoCompact = false;
92
101
  if (args.noWarm) config.warm = false;
93
102
  if (args.model) config.model = args.model;
@@ -128,18 +137,51 @@ async function boot() {
128
137
  }
129
138
  if (!config.model) config.model = pickDefault(ids);
130
139
 
140
+ // Resolved against what Kronk is actually serving, exactly like the main
141
+ // model: a substring in the config file that matches nothing would otherwise
142
+ // only surface as a 404 in the middle of somebody's first delegated task.
143
+ if (config.subagentModel) {
144
+ const subs = ids.filter((id) => id.includes(config.subagentModel));
145
+ const hit = ids.find((id) => id === config.subagentModel)
146
+ ?? subs.find((id) => id.endsWith('/AGENT'))
147
+ ?? subs[0];
148
+ if (hit) config.subagentModel = hit;
149
+ else {
150
+ console.error(c.yellow(` no model matching "${config.subagentModel}" for sub-agents`
151
+ + ' — they will use the main model'));
152
+ config.subagentModel = null;
153
+ }
154
+ }
155
+
131
156
  if (config.warm) await ensureLoaded(ids);
132
157
 
133
- const {
134
- configured, native, preserveThinking, samplingDiff,
135
- } = await modelLimits(config.model);
136
- config.contextWindow = configured;
137
- config.nativeContext = native;
138
- config.templatePreservesThinking = preserveThinking;
139
- config.samplingOverride = samplingDiff;
158
+ // After `ensureLoaded`, which may have fallen back to a different model
159
+ // than the one asked for. Reading the profile before that point would
160
+ // describe a model this session is not using.
161
+ applyLimits(await modelLimits(config.model));
162
+
163
+ // The sub-agent model's profile is its own. Resolved once here rather than
164
+ // on the first delegation, where the wait would land mid-task.
165
+ config.subagentLimits = config.subagentModel
166
+ ? await modelLimits(config.subagentModel)
167
+ : null;
140
168
  return ids;
141
169
  }
142
170
 
171
+ /**
172
+ * Move the session onto another model, limits and all.
173
+ *
174
+ * `/model` used to assign `config.model` and stop there, so a switch to a 32k
175
+ * model kept compacting against the previous model's 131k window and kept
176
+ * sending `preserve_thinking` to a template that never declared it. Every
177
+ * limit is a property of the selected model, so every limit is re-read here.
178
+ */
179
+ async function switchModel(id) {
180
+ config.model = id;
181
+ applyLimits(await modelLimits(id));
182
+ return id;
183
+ }
184
+
143
185
  const gb = (n) => `${(n / 1e9).toFixed(1)} GB`;
144
186
 
145
187
  /** Connect the MCP servers the user asked for. Never fatal. */
@@ -244,6 +286,7 @@ const HELP = `
244
286
  ${c.bold('/think')} turn reasoning off entirely (much faster)
245
287
  ${c.bold('/auto')} autonomous mode: auto-approve tools, run to completion
246
288
  ${c.bold('/steps [n|off]')} cap tool calls per task (default: unlimited)
289
+ ${c.bold('/agents')} sub-agents you can delegate to with the task tool
247
290
  ${c.bold('/mcp')} list attached MCP servers and their tools
248
291
  ${c.bold('/context')} how much of the context window is used
249
292
  ${c.bold('/compact')} replace the conversation with a summary of itself
@@ -260,6 +303,9 @@ async function systemMessage(auto) {
260
303
  const base = auto ? SYSTEM_AUTO : SYSTEM;
261
304
  if (NO_CONTEXT) return { content: base, ctx: null };
262
305
  const ctx = await projectContext(process.cwd());
306
+ // Kept for the sub-agents, which get the same primer without paying for the
307
+ // scan again — see systemFor in src/subagent.js.
308
+ config.projectPrimer = ctx.text;
263
309
  const budget = config.contextWindow
264
310
  ? `\n\nYour context window is ${config.contextWindow.toLocaleString()} tokens, shared by `
265
311
  + `everything in this conversation: these instructions, file contents you read, command `
@@ -500,6 +546,21 @@ async function main() {
500
546
  console.log(c.grey(` thinking display ${config.showThinking ? 'on' : 'off'}`));
501
547
  continue;
502
548
  }
549
+ if (input === '/agents') {
550
+ if (!config.subagents) {
551
+ console.log(c.grey(' delegation is off — drop --no-subagents / KRONK_SUBAGENTS=false'));
552
+ continue;
553
+ }
554
+ console.log(c.grey('\n the model delegates with the task tool — one task, its own context,'));
555
+ console.log(c.grey(' and only its report comes back. You still approve its writes and commands.'));
556
+ for (const [name, a] of Object.entries(AGENTS)) {
557
+ console.log(` ${c.green('●')} ${c.bold(name)} ${c.grey(`· ${a.tools.join(', ')}`)}`);
558
+ console.log(c.grey(` for ${a.use}`));
559
+ }
560
+ console.log(c.grey(` model: ${config.subagentModel ?? config.model} · `
561
+ + `cap ${config.subagentSteps} steps per task\n`));
562
+ continue;
563
+ }
503
564
  if (input === '/models') { await showModels(); continue; }
504
565
  if (input === '/mcp') {
505
566
  if (!mcp || !mcp.routes.size) { console.log(c.grey(' no MCP servers attached — start with --mcp')); continue; }
@@ -538,8 +599,11 @@ async function main() {
538
599
  ?? subs.find((m) => m.endsWith('/AGENT'))
539
600
  ?? subs[0];
540
601
  if (!hit) { console.log(c.red(` no model matching "${want}"`)); continue; }
541
- config.model = hit;
602
+ await switchModel(hit);
542
603
  console.log(c.green(` switched to ${hit}`));
604
+ console.log(c.grey(` window ${config.contextWindow?.toLocaleString() ?? 'unknown'}`
605
+ + ` · output cap ${config.maxTokens.toLocaleString()}`
606
+ + `${config.maxTokensExplicit ? ' (yours)' : ' (from its profile)'}`));
543
607
  continue;
544
608
  }
545
609
  if (input.startsWith('/file ')) {
@@ -0,0 +1,218 @@
1
+ import { def, TOOLS } from './tools.js';
2
+ import { config } from './config.js';
3
+ import { c } from './ui.js';
4
+
5
+ /**
6
+ * Sub-agents: one delegated task, one throwaway context.
7
+ *
8
+ * The point is the same one src/distill.js makes about command output, moved
9
+ * up a level. A survey that reads thirty files to answer one question costs
10
+ * the main conversation thirty files' worth of window, permanently, and it is
11
+ * still paying for them ten turns later. Handing that survey to a sub-agent
12
+ * costs the conversation exactly one tool result: the report. Everything the
13
+ * sub-agent read is discarded with its context.
14
+ *
15
+ * So this is not parallelism — a local Kronk server has one resident copy of
16
+ * the model and calls queue behind each other, so two sub-agents at once is
17
+ * slower than two in sequence, not faster. It is context economy. Delegate
18
+ * what is expensive to read and cheap to conclude.
19
+ */
20
+
21
+ /** Look, never touch. Every sub-agent gets at least these. */
22
+ const READ_ONLY = ['read_file', 'list_dir', 'search'];
23
+
24
+ /**
25
+ * Rules every sub-agent is held to, whatever it is allowed to touch.
26
+ *
27
+ * The first two carry the whole contract. A sub-agent that reports "I found
28
+ * the bug" without saying where is a sub-agent whose entire context was spent
29
+ * for nothing — the caller cannot go and look, because the files it read are
30
+ * gone.
31
+ */
32
+ const CONTRACT = `You are a sub-agent of kronk-cli, running fully offline on the user's machine.
33
+
34
+ You have been given one self-contained task and a context of your own.
35
+
36
+ - Nobody sees your work. The agent that delegated this cannot see the files you read, the commands you ran, or your reasoning — your final reply is the only thing it ever receives.
37
+ - So the reply has to stand alone: the answer, the exact paths and line numbers behind it, whatever failed, and whatever you had to assume. Quote the few lines that matter verbatim; never paste whole files, because not having to hold them is the reason you were given this task.
38
+ - Do not ask questions. There is nobody to answer one. Take the most reasonable reading of the task, act on it, and say what you assumed.
39
+ - Do not stop half way and report progress. Finish the task, then report.
40
+ - Keep the reply under about 30 lines.
41
+ - The working directory is the user's project root. Paths are relative to it. You are already inside it. Do not \`cd\` above it.`;
42
+
43
+ /**
44
+ * The roles a sub-agent can be given.
45
+ *
46
+ * Two, not ten. Each one is a line the delegating model has to read and choose
47
+ * between on every call, and a local model asked to pick between eight
48
+ * near-synonyms picks badly — the split that actually changes what can happen
49
+ * is "can it touch anything", so that is the split.
50
+ */
51
+ export const AGENTS = {
52
+ explore: {
53
+ use: 'reading, searching, tracing how something works, answering a question about the code',
54
+ tools: READ_ONLY,
55
+ rules: '- You can only read: read_file, list_dir and search. You cannot run commands and you cannot change anything. If the task needs a command run or a file written, say so in your reply rather than trying.',
56
+ },
57
+ code: {
58
+ use: 'making a change, running a build or a test suite, reproducing a failure',
59
+ tools: [...READ_ONLY, 'write_file', 'bash'],
60
+ rules: '- You can also write files and run commands. The user is still asked before each one, and a refusal is an answer: report it, do not look for a way around it.\n'
61
+ + '- After you write code, RUN it and fix what breaks. Never report that something works unless you have executed it and seen the output.',
62
+ },
63
+ };
64
+
65
+ const agentList = () => Object.entries(AGENTS)
66
+ .map(([name, a]) => `"${name}" — ${a.use}`).join('; ');
67
+
68
+ /**
69
+ * The instruction the delegating model actually reads.
70
+ *
71
+ * Same reasoning as set_plan's description in src/tools.js: a description is
72
+ * the one place a small model reliably looks, so the whole protocol lives
73
+ * here — what it is for, that the sub-agent starts blank, and that a vague
74
+ * prompt is the failure mode.
75
+ */
76
+ export const TASK_TOOL = def('task',
77
+ 'Delegate one self-contained piece of work to a sub-agent that runs in its own context. '
78
+ + 'Use it when getting the answer means reading or running a lot but the answer itself is '
79
+ + 'small — surveys, searches across many files, reproducing a failure. What the sub-agent '
80
+ + 'reads never enters this conversation; only its report does. The sub-agent cannot see this '
81
+ + 'conversation and cannot ask you anything, so the prompt must carry everything it needs and '
82
+ + 'must say exactly what to report back. It cannot delegate further. Prefer one well-scoped '
83
+ + 'task over several vague ones.',
84
+ {
85
+ agent: {
86
+ type: 'string',
87
+ enum: Object.keys(AGENTS),
88
+ description: `Which sub-agent to run: ${agentList()}.`,
89
+ },
90
+ prompt: {
91
+ type: 'string',
92
+ description: 'The whole task, written for someone who knows nothing about this '
93
+ + 'conversation: what to do, where to look, and exactly what to report back.',
94
+ },
95
+ },
96
+ ['agent', 'prompt']);
97
+
98
+ /**
99
+ * The delegation tool, when it is allowed at all.
100
+ *
101
+ * Empty below the top level, and that is the recursion guard: a sub-agent is
102
+ * never handed the tool that would let it spawn one. Nothing else enforces
103
+ * depth, and nothing else needs to.
104
+ */
105
+ export function taskTools(depth = 0) {
106
+ return depth === 0 && config.subagents ? [TASK_TOOL] : [];
107
+ }
108
+
109
+ /** The built-ins this role is allowed, in the order src/tools.js defines them. */
110
+ export function subagentTools(agent) {
111
+ const allowed = new Set(AGENTS[agent]?.tools ?? READ_ONLY);
112
+ return TOOLS.filter((t) => allowed.has(t.function.name));
113
+ }
114
+
115
+ /**
116
+ * The sub-agent's system prompt: the contract, its role's rules, and the same
117
+ * project primer the main agent was given at startup.
118
+ *
119
+ * The primer is worth its tokens — without it every sub-agent spends its first
120
+ * two steps working out what the project is, in a context it only gets one of.
121
+ */
122
+ export function systemFor(agent) {
123
+ const spec = AGENTS[agent];
124
+ const primer = config.projectPrimer ? `\n\n---\n\n${config.projectPrimer}` : '';
125
+ return `${CONTRACT}\n${spec.rules}${primer}`;
126
+ }
127
+
128
+ /** Nest one line of a sub-agent's output under the call that started it. */
129
+ const nest = (line) => String(line).split('\n')
130
+ .map((l) => `${c.grey(' │')} ${l}`).join('\n');
131
+
132
+ const MAX_REPORT_LINES = 14;
133
+
134
+ /** The report on screen, dimmed and bounded — the caller received all of it. */
135
+ function reportLines(text) {
136
+ const lines = text.split('\n');
137
+ const shown = lines.slice(0, MAX_REPORT_LINES).map((l) => c.grey(` ${l}`));
138
+ const hidden = lines.length - shown.length;
139
+ if (hidden > 0) {
140
+ shown.push(c.grey(` …${hidden} more line${hidden === 1 ? '' : 's'} (the agent received all of it)`));
141
+ }
142
+ return shown;
143
+ }
144
+
145
+ /**
146
+ * The sub-agent's last word, which is the entire value of the call.
147
+ *
148
+ * A turn always ends on an assistant message — see `endTurn` in src/agent.js —
149
+ * so the tail is the report. An empty one is reported as the error it is
150
+ * rather than handed back as a blank tool result the caller would have to
151
+ * interpret.
152
+ */
153
+ function reportFrom(messages) {
154
+ const last = [...messages].reverse().find((m) => m.role === 'assistant');
155
+ const text = String(last?.content ?? '').trim();
156
+ if (!text) return 'error: the sub-agent finished without reporting anything. Do the work here, or delegate again with a more specific prompt.';
157
+ return text;
158
+ }
159
+
160
+ /**
161
+ * Run one delegated task to completion and hand back only its report.
162
+ *
163
+ * `run` is `runTurn` from src/agent.js, passed in rather than imported: the
164
+ * dispatch for this tool lives in that loop, so importing it back would be a
165
+ * cycle for nothing. `approve` and `grant` are the caller's own, unchanged —
166
+ * delegation is not a way to get a command past the user.
167
+ */
168
+ export async function runTask(args, {
169
+ run, model, signal, approve, grant, out = console.log, maxSteps = config.subagentSteps,
170
+ }) {
171
+ const agent = args?.agent ?? 'explore';
172
+ const prompt = String(args?.prompt ?? '').trim();
173
+ if (!AGENTS[agent]) {
174
+ return `error: unknown agent "${agent}" — use one of: ${Object.keys(AGENTS).join(', ')}`;
175
+ }
176
+ if (!prompt) return 'error: task needs a prompt saying what to do and what to report back';
177
+
178
+ const messages = [
179
+ { role: 'system', content: systemFor(agent) },
180
+ { role: 'user', content: prompt },
181
+ ];
182
+
183
+ // A sub-agent on another model gets that model's window and output cap, not
184
+ // the session's. Compacting a 32k sub-agent at 85% of the main model's 131k
185
+ // means never compacting it at all.
186
+ const own = config.subagentModel ? config.subagentLimits : null;
187
+
188
+ const done = await run({
189
+ messages,
190
+ // A smaller model for the grunt work is the whole reason this is a knob:
191
+ // the survey is reading and grepping, the synthesis is not.
192
+ model: config.subagentModel ?? model,
193
+ window: own ? own.contextWindow : config.contextWindow,
194
+ maxTokens: own?.maxTokens ?? config.maxTokens,
195
+ signal,
196
+ approve,
197
+ grant,
198
+ // No MCP: a local model already struggles past ~25 tools, and the
199
+ // sub-agent's list is meant to be the short one.
200
+ mcp: null,
201
+ tools: subagentTools(agent),
202
+ // The checklist is module state belonging to the task the user asked for.
203
+ // A sub-agent that called set_plan would clear it out from under the agent
204
+ // that delegated to it.
205
+ plan: false,
206
+ // Its reasoning and prose are not printed: they are not the answer, and a
207
+ // second stream interleaved with the caller's is unreadable. Tool calls
208
+ // still show, indented, so the run is not a black box.
209
+ stream: false,
210
+ out: (line) => out(nest(line)),
211
+ maxSteps,
212
+ depth: 1,
213
+ });
214
+
215
+ const report = reportFrom(done);
216
+ reportLines(report).forEach((l) => out(l));
217
+ return report;
218
+ }
package/src/tools.js CHANGED
@@ -206,7 +206,13 @@ export function safe(p) {
206
206
  return abs;
207
207
  }
208
208
 
209
- const def = (name, description, properties, required) => ({
209
+ /**
210
+ * A tool definition in the shape the OpenAI-compatible API wants.
211
+ *
212
+ * Exported because src/subagent.js defines a tool too — one that is only
213
+ * offered at the top level, so it cannot live in TOOLS below.
214
+ */
215
+ export const def = (name, description, properties, required) => ({
210
216
  type: 'function',
211
217
  function: { name, description, parameters: { type: 'object', properties, required } },
212
218
  });
@@ -284,6 +290,7 @@ export function describe(name, args) {
284
290
  case 'search': return `search /${args.pattern}/ in ${args.path ?? '.'}`;
285
291
  case 'bash': return `bash: ${args.cmd}`;
286
292
  case 'set_plan': return `plan: ${args.items?.length ?? 0} items`;
293
+ case 'task': return `task ${args.agent ?? 'explore'}: ${String(args.prompt ?? '').split('\n')[0]}`;
287
294
  default: return `${name}(${JSON.stringify(args)})`;
288
295
  }
289
296
  }