kronk-cli 0.3.1 → 0.5.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 +190 -2
- package/package.json +1 -1
- package/src/agent.js +100 -49
- package/src/argv.js +2 -0
- package/src/client.js +36 -16
- package/src/compact.js +8 -5
- package/src/config.js +51 -2
- package/src/distill.js +4 -1
- package/src/index.js +109 -19
- package/src/markdown.js +136 -0
- package/src/prompt.js +79 -0
- package/src/subagent.js +218 -0
- package/src/theme.js +226 -0
- package/src/tools.js +8 -1
- package/src/ui.js +22 -11
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 |
|
|
@@ -625,6 +626,46 @@ yellow past 70%, red past 90%. Toggling `/auto`, `/think` or `/steps` updates it
|
|
|
625
626
|
|
|
626
627
|
The per-turn usage line still prints after each response; this one is the running picture.
|
|
627
628
|
|
|
629
|
+
### It follows your terminal's background
|
|
630
|
+
|
|
631
|
+
That grey is the one colour the whole line depends on, and the sixteen-colour palette has exactly
|
|
632
|
+
one of them — bright black, which most terminals render around `#555`. On a light background it
|
|
633
|
+
reads; on a dark one the status line was the hardest thing on screen to see.
|
|
634
|
+
|
|
635
|
+
So the palette is chosen per background, from the 256-colour cube: a lighter grey on a dark
|
|
636
|
+
terminal, a darker one on a light terminal, and every other colour shifted the same way. Which
|
|
637
|
+
background you have is settled once, at startup, in this order:
|
|
638
|
+
|
|
639
|
+
1. `KRONK_THEME`, or `"theme"` in `~/.kronk-cli.json` — `dark` or `light` pins it
|
|
640
|
+
2. `COLORFGBG`, when the terminal exports it
|
|
641
|
+
3. the terminal's own answer to an `OSC 11` background query, waited on for 100 ms
|
|
642
|
+
4. failing all of that, dark — what most terminals ship as
|
|
643
|
+
|
|
644
|
+
`/theme dark` or `/theme light` switches it mid-session, for when step 3 guessed wrong. `NO_COLOR`
|
|
645
|
+
still turns every escape off, and a terminal that advertises no 256-colour support gets a
|
|
646
|
+
sixteen-colour palette picked the same way.
|
|
647
|
+
|
|
648
|
+
---
|
|
649
|
+
|
|
650
|
+
## Markdown in answers
|
|
651
|
+
|
|
652
|
+
The models write markdown whether or not anything renders it, so an answer used to arrive with the
|
|
653
|
+
punctuation still in your way — `### The letter is a notification`, `**binding**`, backticks around
|
|
654
|
+
every identifier. Those constructs are rendered into the attributes the terminal already has:
|
|
655
|
+
headings lose their hashes and gain bold, emphasis becomes bold or italic, code spans and fenced
|
|
656
|
+
blocks get their own colour and a gutter, lists get a `•`, quotes a `│`, and `---` a rule.
|
|
657
|
+
|
|
658
|
+
It is a line renderer, not a markdown parser — one bit of state, whether a fenced block is open —
|
|
659
|
+
and anything it does not recognise is left exactly as written. Prose that only looks like markup
|
|
660
|
+
survives: `snake_case`, `3 * 4`, a lone asterisk, `**kwargs` inside backticks.
|
|
661
|
+
|
|
662
|
+
Two consequences worth knowing:
|
|
663
|
+
|
|
664
|
+
- **Output appears a line at a time**, not a token at a time, because `**` is bold or two
|
|
665
|
+
asterisks depending on what follows it. At local speeds that is still a line every half second.
|
|
666
|
+
- **With colour off the bytes are the model's own.** Piped, redirected, or `NO_COLOR` — nothing is
|
|
667
|
+
rewritten, so anything parsing kronk-cli's output sees exactly what it saw before.
|
|
668
|
+
|
|
628
669
|
---
|
|
629
670
|
|
|
630
671
|
## REPL commands
|
|
@@ -633,14 +674,16 @@ The per-turn usage line still prints after each response; this one is the runnin
|
|
|
633
674
|
|---|---|
|
|
634
675
|
| `/help` | list these commands |
|
|
635
676
|
| `/models` | what Kronk is serving, with sizes and what is resident |
|
|
636
|
-
| `/model <id>` | switch model — substring match, `/AGENT` preferred |
|
|
677
|
+
| `/model <id>` | switch model — substring match, `/AGENT` preferred; re-reads that model's window and caps |
|
|
637
678
|
| `/file <path>` | add a file to the conversation as context |
|
|
638
679
|
| `/auto` | toggle autonomous mode (auto-approve + run to completion) |
|
|
639
680
|
| `/steps [n\|off]` | show or set the tool-call cap |
|
|
640
681
|
| `/thinking` | show or hide the model's reasoning |
|
|
641
682
|
| `/think` | turn reasoning off entirely — much faster |
|
|
683
|
+
| `/agents` | the sub-agents `task` can delegate to, and what each may touch |
|
|
642
684
|
| `/mcp` | list attached MCP servers and their tools |
|
|
643
685
|
| `/context` | how much of the context window is used |
|
|
686
|
+
| `/theme [dark\|light]` | show or pin the palette, for when the terminal was read wrong |
|
|
644
687
|
| `/compact` | replace the conversation with a summary of itself |
|
|
645
688
|
| `/clear` | reset the conversation, keep the model |
|
|
646
689
|
| `/exit`, `/quit` | quit |
|
|
@@ -655,7 +698,7 @@ The per-turn usage line still prints after each response; this one is the runnin
|
|
|
655
698
|
| `KRONK_TOKEN` | `kronk` | Any non-empty value while Kronk runs open; a real JWT when protected |
|
|
656
699
|
| `KRONK_MODEL` | `unsloth/Qwen3.6-35B-A3B-UD-Q4_K_M/AGENT` | Model id |
|
|
657
700
|
| `KRONK_MODEL_CONFIG` | `~/.kronk/models/model_config.yaml` | Kronk's per-model config, the file `kronk-cli setup` writes |
|
|
658
|
-
| `KRONK_MAX_TOKENS` |
|
|
701
|
+
| `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
702
|
| `KRONK_MAX_STEPS` | unlimited | Cap on tool calls per task |
|
|
660
703
|
| `KRONK_THINKING` | `true` | `false` hides reasoning but still generates it |
|
|
661
704
|
| `KRONK_NO_THINK` | — | `1` disables reasoning server-side |
|
|
@@ -668,10 +711,15 @@ The per-turn usage line still prints after each response; this one is the runnin
|
|
|
668
711
|
| `KRONK_CONFIG` | `~/.kronk-cli.json` | Path to the config file, so tests and throwaway runs stay off the real one |
|
|
669
712
|
| `KRONK_DISTILL` | `true` | `false` disables tool-output distillation |
|
|
670
713
|
| `KRONK_DISTILL_AT` | `8000` | Characters of output that trigger distillation |
|
|
714
|
+
| `KRONK_SUBAGENTS` | `true` | `false` removes the `task` tool |
|
|
715
|
+
| `KRONK_SUBAGENT_MODEL` | the main model | Model sub-agents run on — substring match, same as `KRONK_MODEL` |
|
|
716
|
+
| `KRONK_SUBAGENT_STEPS` | `40` | Tool-call cap for one delegated task |
|
|
671
717
|
| `KRONK_WARM` | `true` | `false` skips the boot-time model preload |
|
|
672
718
|
| `KRONK_AUTO_COMPACT` | `true` | `false` disables automatic compaction |
|
|
673
719
|
| `KRONK_COMPACT_AT` | `0.85` | Fraction of the window that triggers compaction |
|
|
720
|
+
| `KRONK_THEME` | `auto` | `dark` or `light` pins the palette; `auto` reads the terminal's background |
|
|
674
721
|
| `NO_COLOR` | — | Any value disables colour |
|
|
722
|
+
| `FORCE_COLOR` | — | `0` disables colour; any other value keeps it on through a pipe |
|
|
675
723
|
|
|
676
724
|
### Config file
|
|
677
725
|
|
|
@@ -685,11 +733,14 @@ The per-turn usage line still prints after each response; this one is the runnin
|
|
|
685
733
|
"maxTokens": 16384,
|
|
686
734
|
"maxSteps": 200,
|
|
687
735
|
"showThinking": false,
|
|
736
|
+
"theme": "auto",
|
|
688
737
|
"autoCompact": true,
|
|
689
738
|
"compactAt": 0.85,
|
|
690
739
|
"noThink": true,
|
|
691
740
|
"preserveThinking": true,
|
|
692
741
|
"replayReasoning": true,
|
|
742
|
+
"subagentModel": "Qwen3.6-4B",
|
|
743
|
+
"subagentSteps": 40,
|
|
693
744
|
"sandboxReadable": ["~/Library/Keychains"]
|
|
694
745
|
}
|
|
695
746
|
```
|
|
@@ -851,6 +902,13 @@ not read `reasoning_content` either and would discard the blocks.
|
|
|
851
902
|
Kronk reports the **effective** window for whichever model id you selected — that comes from
|
|
852
903
|
`context-window` in `model_config.yaml`, so a `/AGENT` profile and its base model can differ.
|
|
853
904
|
|
|
905
|
+
Every limit is read from the selected model's profile, and re-read when the selection changes:
|
|
906
|
+
the window, the output cap (`sampling-parameters.max_tokens`), whether the chat template
|
|
907
|
+
understands `preserve_thinking`, and the model's native maximum. `/model` mid-session moves all
|
|
908
|
+
of them, and a sub-agent running on `KRONK_SUBAGENT_MODEL` compacts against *its* window rather
|
|
909
|
+
than the main model's. The one number that is not taken from the profile is a `KRONK_MAX_TOKENS`
|
|
910
|
+
you set yourself — yours always wins.
|
|
911
|
+
|
|
854
912
|
Three places surface it:
|
|
855
913
|
|
|
856
914
|
**The banner**
|
|
@@ -962,10 +1020,15 @@ Disable with `--no-compact` or `KRONK_AUTO_COMPACT=false` if you would rather se
|
|
|
962
1020
|
| `write_file` | ✋ | Create or overwrite; shows a diff preview first |
|
|
963
1021
|
| `bash` | ✋ | Run a command; shows it first |
|
|
964
1022
|
| `set_plan` | — | Record the task checklist; replaces the stored one |
|
|
1023
|
+
| `task` | — | Delegate one job to a [sub-agent](#sub-agents) with its own context |
|
|
965
1024
|
|
|
966
1025
|
`set_plan` writes nothing and runs nothing — it hands the harness a list, which is why it is
|
|
967
1026
|
never gated. See [Autonomous mode](#autonomous-mode) for what the harness then does with it.
|
|
968
1027
|
|
|
1028
|
+
`task` is not gated either, because it cannot do anything by itself: every write and every
|
|
1029
|
+
command a sub-agent asks for comes back to the same prompt you would have seen had the main
|
|
1030
|
+
agent asked for it.
|
|
1031
|
+
|
|
969
1032
|
`--yes` and `--auto` skip the prompts. Paths resolve against the session directory and cannot
|
|
970
1033
|
escape the launch root — including through a symlink. `bash` additionally runs under an OS
|
|
971
1034
|
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 +1229,127 @@ same way.
|
|
|
1166
1229
|
|
|
1167
1230
|
---
|
|
1168
1231
|
|
|
1232
|
+
## Sub-agents
|
|
1233
|
+
|
|
1234
|
+
The model can hand one job to a **sub-agent**: a fresh agent with its own context window, its
|
|
1235
|
+
own tool loop, and no memory of your conversation. It runs the task, and the only thing that
|
|
1236
|
+
ever comes back is its report.
|
|
1237
|
+
|
|
1238
|
+
```console
|
|
1239
|
+
› which tools in this repo need approval, and where is that decided?
|
|
1240
|
+
|
|
1241
|
+
1 ⚙ task explore: list every tool definition in src/, say which are gated and where
|
|
1242
|
+
│ 1/40 ⚙ search /def\(/ in src
|
|
1243
|
+
│ ✓ 9 lines
|
|
1244
|
+
│ 2/40 ⚙ read src/tools.js
|
|
1245
|
+
│ ✓ 587 lines
|
|
1246
|
+
│ 3/40 ⚙ read src/subagent.js
|
|
1247
|
+
│ ✓ 211 lines
|
|
1248
|
+
│ 2104→380 tok · 58.1 tok/s · 1980 cached
|
|
1249
|
+
Six built-in tools. write_file and bash are gated, by NEEDS_APPROVAL at
|
|
1250
|
+
src/tools.js:261. MCP tools are gated by name, mcpNeedsApproval at :270.
|
|
1251
|
+
read_file, list_dir, search, set_plan and task are not gated.
|
|
1252
|
+
✓ 3 lines
|
|
1253
|
+
|
|
1254
|
+
`write_file` and `bash`, from the `NEEDS_APPROVAL` set in src/tools.js:246…
|
|
1255
|
+
```
|
|
1256
|
+
|
|
1257
|
+
`│` marks the sub-agent's own loop; the unprefixed lines are its report, which is what the main
|
|
1258
|
+
agent receives as the tool result.
|
|
1259
|
+
|
|
1260
|
+
Three files were read. None of them is in your conversation: the 800-odd lines they cost were
|
|
1261
|
+
spent in a context that no longer exists, and all that is left in your window is the four-line
|
|
1262
|
+
answer. That is the whole point, and it is the trade [distillation](#distillation) makes for
|
|
1263
|
+
command output, one level up.
|
|
1264
|
+
|
|
1265
|
+
Both agents run against the same Kronk server, so nothing leaves the machine that was not
|
|
1266
|
+
already leaving it.
|
|
1267
|
+
|
|
1268
|
+
### It is not parallelism
|
|
1269
|
+
|
|
1270
|
+
Kronk holds one resident copy of the model, so two sub-agents at once queue behind each other
|
|
1271
|
+
and finish no sooner than two in sequence. Delegation here buys **context**, not wall-clock:
|
|
1272
|
+
give away the work that is expensive to read and cheap to conclude.
|
|
1273
|
+
|
|
1274
|
+
### What it costs, measured
|
|
1275
|
+
|
|
1276
|
+
The same survey of `src/` — one table row per module, its responsibility and the gotcha its own
|
|
1277
|
+
comments call out, plus three facts quoted with `file:line` — run twice per model on this
|
|
1278
|
+
machine, once with `--no-subagents` and once told to orchestrate. `main ctx peak` is the largest
|
|
1279
|
+
prompt the *main* conversation ever sent, which is the number the feature exists to move:
|
|
1280
|
+
|
|
1281
|
+
| model | mode | wall | main ctx peak | tokens generated | answer |
|
|
1282
|
+
|---|---|---|---|---|---|
|
|
1283
|
+
| Qwen3.6-35B-A3B `/AGENT` | plain | 143s | 30.8k | 5.5k | 23/23 |
|
|
1284
|
+
| Qwen3.6-35B-A3B `/AGENT` | orchestrated, 5 sub-agents | 352s | **12.9k** | 16.2k | 22/23 |
|
|
1285
|
+
| Ornith-1.5-35B `/AGENT` | plain | 154s | 34.4k | 5.8k | 23/23 |
|
|
1286
|
+
| Ornith-1.5-35B `/AGENT` | orchestrated, 5 sub-agents | 357s | **5.9k** | 16.5k | 23/23 |
|
|
1287
|
+
|
|
1288
|
+
Delegation cut the main conversation's high-water mark by 58% and 83%, and cost roughly 2.4×
|
|
1289
|
+
the wall clock and 3× the generated tokens to do it. That is the trade in one line: **you are
|
|
1290
|
+
buying window, and paying for it in time and tokens.** It is worth it when the conversation has
|
|
1291
|
+
to keep going afterwards, and not worth it for a question you were going to ask once.
|
|
1292
|
+
|
|
1293
|
+
The one answer that got worse is worth reading too. Qwen's orchestrated run scored 22/23 because
|
|
1294
|
+
the sub-agent it asked for "the constant that caps a tool result" came back with a plausible
|
|
1295
|
+
wrong one from a different module, and the orchestrator had no way to know: it never read the
|
|
1296
|
+
file. The plain run made the same mistake, spent three rounds re-reading `tools.js`, and caught
|
|
1297
|
+
it. **A sub-agent's report is evidence you cannot cross-examine** — ask for `file:line` and
|
|
1298
|
+
verbatim quotes in the prompt, and check the ones that matter.
|
|
1299
|
+
|
|
1300
|
+
### The two agents
|
|
1301
|
+
|
|
1302
|
+
| Agent | Tools | For |
|
|
1303
|
+
|---|---|---|
|
|
1304
|
+
| `explore` | `read_file`, `list_dir`, `search` | Reading, searching, tracing how something works |
|
|
1305
|
+
| `code` | those plus `write_file`, `bash` | Making a change, running a build, reproducing a failure |
|
|
1306
|
+
|
|
1307
|
+
`/agents` prints this in the REPL, with the model and step cap in force.
|
|
1308
|
+
|
|
1309
|
+
Two roles rather than a directory of them, because every extra one is a line the model has to
|
|
1310
|
+
read and choose between on each call, and a local model asked to pick between eight
|
|
1311
|
+
near-synonyms picks badly. The split that changes what can actually happen is whether it may
|
|
1312
|
+
touch anything, so that is the split.
|
|
1313
|
+
|
|
1314
|
+
### What it cannot do
|
|
1315
|
+
|
|
1316
|
+
- **Ask you anything.** There is nobody in its loop. It is told to take the most reasonable
|
|
1317
|
+
reading of the task, act, and say what it assumed.
|
|
1318
|
+
- **Delegate further.** The `task` tool is only ever offered at the top level, which is the
|
|
1319
|
+
entire recursion guard.
|
|
1320
|
+
- **Get a command past you.** Its `write_file` and `bash` calls arrive at the same prompt the
|
|
1321
|
+
main agent's do, nested under the call that started them. `--yes` and `--auto` skip them
|
|
1322
|
+
exactly as they always did.
|
|
1323
|
+
- **Touch the checklist.** `set_plan` belongs to the task you asked for; a sub-agent has no
|
|
1324
|
+
access to it.
|
|
1325
|
+
- **Reach MCP servers.** Its tool list is deliberately the short one.
|
|
1326
|
+
|
|
1327
|
+
It also runs under a step cap — 40 by default, where the main agent's is unlimited — because
|
|
1328
|
+
nobody is watching it, and a report is worth less than the window a runaway loop would spend
|
|
1329
|
+
earning it.
|
|
1330
|
+
|
|
1331
|
+
### Knobs
|
|
1332
|
+
|
|
1333
|
+
```bash
|
|
1334
|
+
kronk-cli --no-subagents # remove the task tool entirely
|
|
1335
|
+
KRONK_SUBAGENT_MODEL=<id> kronk-cli # run the grunt work on a different model
|
|
1336
|
+
KRONK_SUBAGENT_STEPS=80 kronk-cli # a longer leash per task
|
|
1337
|
+
```
|
|
1338
|
+
|
|
1339
|
+
A sub-agent gets the same startup scan of your project the main agent got, so it starts knowing
|
|
1340
|
+
what the repo is instead of spending its first two steps finding out.
|
|
1341
|
+
|
|
1342
|
+
`KRONK_SUBAGENT_MODEL` needs a model that can actually drive a tool loop, and "smaller" is not
|
|
1343
|
+
the same thing as "cheaper" here. Measured on the survey task below with `qwen2.5-coder-1.5b`
|
|
1344
|
+
as the sub-agent model: every sub-agent returned prose without calling a single tool, the main
|
|
1345
|
+
agent noticed, read all eighteen modules itself, and finished with **47.1k** of context against
|
|
1346
|
+
the 30.8k it uses when you never delegate at all. A sub-agent that cannot do the work costs you
|
|
1347
|
+
the delegation *and* the work.
|
|
1348
|
+
|
|
1349
|
+
> A small model will not delegate unprompted as often as a frontier one does. Asking for it
|
|
1350
|
+
> works — *"use a sub-agent to survey the test suite first"* — and is usually how you will
|
|
1351
|
+
> drive this.
|
|
1352
|
+
|
|
1169
1353
|
## Autonomous mode
|
|
1170
1354
|
|
|
1171
1355
|
`--auto`, or `/auto` in the REPL, swaps in a system prompt that tells the model to finish the
|
|
@@ -1245,10 +1429,14 @@ previous prompt prefix — watch `cached` climb in the usage line.
|
|
|
1245
1429
|
| `src/context.js` | startup scan: git, layout, `AGENTS.md` |
|
|
1246
1430
|
| `src/compact.js` | summarizing the conversation when the window fills |
|
|
1247
1431
|
| `src/plan.js` | the task checklist and the snapshot the model sees |
|
|
1432
|
+
| `src/subagent.js` | delegation: agent roles, their tools, the report that comes back |
|
|
1248
1433
|
| `src/mcp.js` | MCP client: stdio + HTTP transports, tool routing |
|
|
1249
1434
|
| `src/distill.js` | summarizing large tool output in a throwaway context |
|
|
1250
1435
|
| `src/config.js` | precedence of flags, env, config file |
|
|
1251
1436
|
| `src/ui.js` | colour, spinner, usage formatting |
|
|
1437
|
+
| `src/theme.js` | which palette to print in, and how the background is worked out |
|
|
1438
|
+
| `src/markdown.js` | rendering the model's markdown for the terminal, as it streams |
|
|
1439
|
+
| `src/prompt.js` | the line reader: opened on the first question, keeping what was typed before it |
|
|
1252
1440
|
|
|
1253
1441
|
---
|
|
1254
1442
|
|
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -6,11 +6,13 @@ import {
|
|
|
6
6
|
import { config, shouldPreserveThinking } from './config.js';
|
|
7
7
|
import { forRequest } from './reasoning.js';
|
|
8
8
|
import { c, fmtUsage, spinner, liveLine, toolResultLines } from './ui.js';
|
|
9
|
+
import { markdownStream } from './markdown.js';
|
|
9
10
|
import { compact, isOverflow, report } from './compact.js';
|
|
10
11
|
import { maybeDistill } from './distill.js';
|
|
11
12
|
import {
|
|
12
13
|
carryChecklist, clearPlan, openItems, outstandingLines, planLines, pushNudge,
|
|
13
14
|
} from './plan.js';
|
|
15
|
+
import { runTask, taskTools, TASK_TOOL } from './subagent.js';
|
|
14
16
|
|
|
15
17
|
export const SYSTEM = `You are kronk-cli, a terse coding assistant running fully offline on the user's machine.
|
|
16
18
|
|
|
@@ -50,11 +52,11 @@ const thought = (reasoning) => (reasoning ? { reasoning_content: reasoning } : {
|
|
|
50
52
|
* view as the wire request that just overflowed — see `compact` in
|
|
51
53
|
* src/compact.js.
|
|
52
54
|
*/
|
|
53
|
-
async function compactInto(messages,
|
|
54
|
-
const res = await compact(messages,
|
|
55
|
-
if (res.failed || res.skipped) {
|
|
55
|
+
async function compactInto(messages, { out = console.log, ...opts }) {
|
|
56
|
+
const res = await compact(messages, opts);
|
|
57
|
+
if (res.failed || res.skipped) { out(report(res)); return false; }
|
|
56
58
|
messages.splice(0, messages.length, ...res.messages);
|
|
57
|
-
|
|
59
|
+
out(report(res));
|
|
58
60
|
return true;
|
|
59
61
|
}
|
|
60
62
|
|
|
@@ -68,8 +70,8 @@ const MAX_NUDGES = 2;
|
|
|
68
70
|
* message — the reply, or the step-cap note — so a nudge is never left sitting
|
|
69
71
|
* directly before the next typed prompt.
|
|
70
72
|
*/
|
|
71
|
-
function endTurn(messages) {
|
|
72
|
-
outstandingLines().forEach((l) =>
|
|
73
|
+
function endTurn(messages, { out = console.log, plan = true } = {}) {
|
|
74
|
+
if (plan) outstandingLines().forEach((l) => out(l));
|
|
73
75
|
return messages;
|
|
74
76
|
}
|
|
75
77
|
|
|
@@ -86,8 +88,14 @@ function endTurn(messages) {
|
|
|
86
88
|
*/
|
|
87
89
|
export async function runTurn({
|
|
88
90
|
messages, model, signal, approve, grant, mcp, auto = false, maxSteps = config.maxSteps,
|
|
91
|
+
tools: toolset = TOOLS, plan: usePlan = true, out = console.log, stream = true, depth = 0,
|
|
92
|
+
// The window and the output cap belong to `model`, not to the session: a
|
|
93
|
+
// sub-agent may be running on a different model with a different profile.
|
|
94
|
+
window = config.contextWindow, maxTokens = config.maxTokens,
|
|
89
95
|
}) {
|
|
90
|
-
|
|
96
|
+
// `taskTools` is empty below the top level, which is the whole recursion
|
|
97
|
+
// guard: a sub-agent is never handed the tool that spawns one.
|
|
98
|
+
const tools = [...toolset, ...taskTools(depth), ...(mcp ? mcp.toolDefs() : [])];
|
|
91
99
|
let totalUsage = null;
|
|
92
100
|
let step = 0;
|
|
93
101
|
let compacted = false;
|
|
@@ -97,22 +105,30 @@ export async function runTurn({
|
|
|
97
105
|
// a checklist the previous turn left in the transcript is history, it is
|
|
98
106
|
// already in the prompt prefix, and rewriting history is what evicts the
|
|
99
107
|
// prompt cache — see `carryChecklist`.
|
|
100
|
-
clearPlan();
|
|
108
|
+
if (usePlan) clearPlan();
|
|
101
109
|
|
|
102
110
|
for (;;) {
|
|
103
111
|
step += 1;
|
|
104
112
|
if (Number.isFinite(maxSteps) && step > maxSteps) {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
113
|
+
out(c.yellow(` ⛔ step cap reached (${maxSteps}). Stopping.`));
|
|
114
|
+
out(c.grey(depth
|
|
115
|
+
? ' raise it with KRONK_SUBAGENT_STEPS'
|
|
116
|
+
: ' raise it with --steps N, or /steps N in the REPL'));
|
|
117
|
+
messages.push({ role: 'assistant',
|
|
118
|
+
content: depth
|
|
119
|
+
? `(stopped: the sub-agent reached its step cap of ${maxSteps} before finishing)`
|
|
120
|
+
: '(stopped: step cap reached)' });
|
|
121
|
+
return endTurn(messages, { out, plan: usePlan });
|
|
109
122
|
}
|
|
110
|
-
let sp = spinner('thinking');
|
|
123
|
+
let sp = spinner(depth ? 'sub-agent' : 'thinking');
|
|
111
124
|
let text = '';
|
|
112
125
|
let reasoning = '';
|
|
113
126
|
let calls = [];
|
|
114
127
|
let wroteAnything = false;
|
|
115
128
|
let inReasoning = false;
|
|
129
|
+
// One renderer per step: a fence left open by one answer must not colour
|
|
130
|
+
// the next one as code.
|
|
131
|
+
const md = markdownStream();
|
|
116
132
|
|
|
117
133
|
try {
|
|
118
134
|
for await (const ev of streamChat({
|
|
@@ -122,7 +138,7 @@ export async function runTurn({
|
|
|
122
138
|
messages: forRequest(messages, auto),
|
|
123
139
|
tools,
|
|
124
140
|
signal,
|
|
125
|
-
maxTokens
|
|
141
|
+
maxTokens,
|
|
126
142
|
noThink: config.noThink,
|
|
127
143
|
// Re-read every step: this has to hold for the tool-loop follow-ups too,
|
|
128
144
|
// and `/think` can flip the answer between one turn and the next.
|
|
@@ -132,7 +148,9 @@ export async function runTurn({
|
|
|
132
148
|
// Accumulated before the display check: whether the user watches the
|
|
133
149
|
// model think has nothing to do with whether the model gets it back.
|
|
134
150
|
reasoning += ev.value;
|
|
135
|
-
|
|
151
|
+
// A sub-agent's reasoning is not the answer, and a second stream
|
|
152
|
+
// interleaved with the caller's is unreadable. See runTask.
|
|
153
|
+
if (!stream || !config.showThinking) continue;
|
|
136
154
|
if (sp) { sp.stop(); sp = null; }
|
|
137
155
|
if (!inReasoning) { process.stdout.write(c.grey('\n ┄ thinking ┄\n ')); inReasoning = true; }
|
|
138
156
|
process.stdout.write(c.grey(ev.value.replace(/\n/g, '\n ')));
|
|
@@ -140,10 +158,14 @@ export async function runTurn({
|
|
|
140
158
|
}
|
|
141
159
|
|
|
142
160
|
else if (ev.type === 'text') {
|
|
161
|
+
text += ev.value;
|
|
162
|
+
if (!stream) continue;
|
|
143
163
|
if (sp) { sp.stop(); sp = null; }
|
|
144
164
|
if (inReasoning) { process.stdout.write(c.grey('\n ┄─────────┄\n\n')); inReasoning = false; }
|
|
145
|
-
text
|
|
146
|
-
|
|
165
|
+
// Rendered a line at a time — see src/markdown.js. `text` keeps the
|
|
166
|
+
// model's own copy: what goes back in the conversation is never the
|
|
167
|
+
// screen's version of it.
|
|
168
|
+
process.stdout.write(md.write(ev.value));
|
|
147
169
|
wroteAnything = true;
|
|
148
170
|
}
|
|
149
171
|
|
|
@@ -159,13 +181,13 @@ export async function runTurn({
|
|
|
159
181
|
if (sp) { sp.stop(); sp = null; }
|
|
160
182
|
if (isOverflow(e) && !compacted) {
|
|
161
183
|
compacted = true;
|
|
162
|
-
|
|
184
|
+
out(c.yellow('\n context full — compacting and retrying'));
|
|
163
185
|
// The plan itself is module state, so compaction cannot reach it. The
|
|
164
186
|
// snapshot it just summarised away is not put back: there is no tool
|
|
165
187
|
// result left to carry one, and inserting a message here would be the
|
|
166
188
|
// rewrite `carryChecklist` exists to avoid. The next round's tool
|
|
167
189
|
// results carry the plan again.
|
|
168
|
-
if (await compactInto(messages, model, signal, auto)) {
|
|
190
|
+
if (await compactInto(messages, { model, signal, auto, out, window, maxTokens })) {
|
|
169
191
|
step -= 1;
|
|
170
192
|
continue;
|
|
171
193
|
}
|
|
@@ -176,17 +198,26 @@ export async function runTurn({
|
|
|
176
198
|
}
|
|
177
199
|
|
|
178
200
|
if (inReasoning) process.stdout.write(c.grey('\n ┄─────────┄\n'));
|
|
201
|
+
// The last line has no newline to trigger it: an answer that ends mid-line
|
|
202
|
+
// would otherwise be held in the renderer and never printed.
|
|
203
|
+
if (stream) process.stdout.write(md.flush());
|
|
179
204
|
if (wroteAnything) process.stdout.write('\n');
|
|
180
205
|
if (totalUsage) {
|
|
181
|
-
|
|
182
|
-
|
|
206
|
+
out(fmtUsage(totalUsage, window));
|
|
207
|
+
// The status line reports the conversation the user is typing into. A
|
|
208
|
+
// sub-agent's window is its own and is thrown away with it.
|
|
209
|
+
if (!depth) {
|
|
210
|
+
config.lastUsed = (totalUsage.prompt_tokens ?? 0) + (totalUsage.completion_tokens ?? 0);
|
|
211
|
+
}
|
|
183
212
|
}
|
|
184
213
|
|
|
185
|
-
if (config.autoCompact &&
|
|
214
|
+
if (config.autoCompact && window && totalUsage) {
|
|
186
215
|
const used = (totalUsage.prompt_tokens ?? 0) + (totalUsage.completion_tokens ?? 0);
|
|
187
|
-
if (used /
|
|
188
|
-
|
|
189
|
-
if (!await compactInto(messages, model, signal, auto))
|
|
216
|
+
if (used / window >= config.compactAt) {
|
|
217
|
+
out(c.yellow(` context ${Math.round((used / window) * 100)}% full — compacting`));
|
|
218
|
+
if (!await compactInto(messages, { model, signal, auto, out, window, maxTokens })) {
|
|
219
|
+
config.autoCompact = false;
|
|
220
|
+
}
|
|
190
221
|
}
|
|
191
222
|
}
|
|
192
223
|
|
|
@@ -195,7 +226,7 @@ export async function runTurn({
|
|
|
195
226
|
if (!text.trim()) {
|
|
196
227
|
// Reasoning models sometimes spend the whole budget thinking and emit no
|
|
197
228
|
// answer. Say so rather than returning silence.
|
|
198
|
-
|
|
229
|
+
out(c.yellow(' (model produced no answer — raise KRONK_MAX_TOKENS or /thinking off)'));
|
|
199
230
|
messages.push({ role: 'assistant', content: '(no answer produced)', ...thought(reasoning) });
|
|
200
231
|
} else {
|
|
201
232
|
messages.push({ role: 'assistant', content: text, ...thought(reasoning) });
|
|
@@ -205,13 +236,13 @@ export async function runTurn({
|
|
|
205
236
|
// keep going — but only when nobody is watching, and only twice: past
|
|
206
237
|
// that the model is not going to finish, and looping is worse than
|
|
207
238
|
// reporting what is left.
|
|
208
|
-
if (auto && openItems().length && nudges < MAX_NUDGES) {
|
|
239
|
+
if (usePlan && auto && openItems().length && nudges < MAX_NUDGES) {
|
|
209
240
|
nudges += 1;
|
|
210
241
|
pushNudge(messages);
|
|
211
|
-
|
|
242
|
+
out(c.yellow(` ⚠ ${openItems().length} checklist items still open — continuing`));
|
|
212
243
|
continue;
|
|
213
244
|
}
|
|
214
|
-
return endTurn(messages);
|
|
245
|
+
return endTurn(messages, { out, plan: usePlan });
|
|
215
246
|
}
|
|
216
247
|
|
|
217
248
|
messages.push({
|
|
@@ -239,17 +270,21 @@ export async function runTurn({
|
|
|
239
270
|
|
|
240
271
|
const at = Number.isFinite(maxSteps) ? `${step}/${maxSteps}` : `${step}`;
|
|
241
272
|
const isMcp = Boolean(mcp?.has(call.name));
|
|
273
|
+
// The same list the model was offered, not just the name it used: with
|
|
274
|
+
// delegation off, or below the top level, a `task` call is a call to a
|
|
275
|
+
// tool that does not exist and falls through to runTool saying so.
|
|
276
|
+
const isTask = !isMcp && call.name === 'task' && tools.includes(TASK_TOOL);
|
|
242
277
|
const label = isMcp
|
|
243
278
|
? `${c.magenta('⚙')} ${call.name} ${c.grey(JSON.stringify(args).slice(0, 120))}`
|
|
244
279
|
: `${c.blue(`⚙ ${describe(call.name, args)}`)}`;
|
|
245
|
-
|
|
280
|
+
out(`${c.grey(` ${at}`)} ${label}`);
|
|
246
281
|
|
|
247
282
|
if (isMcp ? mcpNeedsApproval(call.name) : NEEDS_APPROVAL.has(call.name)) {
|
|
248
283
|
const body = preview(call.name, args);
|
|
249
|
-
if (body)
|
|
284
|
+
if (body) out(body.split('\n').map((l) => ` ${l}`).join('\n'));
|
|
250
285
|
const ok = await approve(call.name, args);
|
|
251
286
|
if (!ok) {
|
|
252
|
-
|
|
287
|
+
out(c.red(' ✗ denied'));
|
|
253
288
|
messages.push({ role: 'tool', tool_call_id: call.id,
|
|
254
289
|
content: 'error: the user denied this action. Ask what to do instead.' });
|
|
255
290
|
continue;
|
|
@@ -276,11 +311,11 @@ export async function runTurn({
|
|
|
276
311
|
if (answer === 'yes' || answer === 'always') {
|
|
277
312
|
try {
|
|
278
313
|
const wrote = rememberGrants(needed, { persist: answer === 'always' });
|
|
279
|
-
if (wrote)
|
|
314
|
+
if (wrote) out(c.grey(` remembered in ${wrote}`));
|
|
280
315
|
} catch (e) {
|
|
281
316
|
// Failing to persist must not fail the command. The grant is
|
|
282
317
|
// already in force for this session either way.
|
|
283
|
-
|
|
318
|
+
out(c.yellow(` ${e.message}`));
|
|
284
319
|
}
|
|
285
320
|
} else {
|
|
286
321
|
declineGrants(needed);
|
|
@@ -288,35 +323,51 @@ export async function runTurn({
|
|
|
288
323
|
}
|
|
289
324
|
}
|
|
290
325
|
|
|
291
|
-
const live = liveLine();
|
|
292
326
|
let result;
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
327
|
+
if (isTask) {
|
|
328
|
+
// No `liveLine` around this one. It rewrites the current line every
|
|
329
|
+
// 120 ms, which would erase the sub-agent's own output as fast as it
|
|
330
|
+
// is printed.
|
|
331
|
+
try {
|
|
332
|
+
result = await runTask(args, { run: runTurn, model, signal, approve, grant, out });
|
|
333
|
+
} catch (e) {
|
|
334
|
+
// Errors are data here exactly as they are in `runTool`: a
|
|
335
|
+
// sub-agent that died does not take the turn that delegated to it
|
|
336
|
+
// down with it.
|
|
337
|
+
result = `error: the sub-agent failed — ${e.message}`;
|
|
338
|
+
}
|
|
339
|
+
} else {
|
|
340
|
+
const live = liveLine();
|
|
341
|
+
try {
|
|
342
|
+
result = isMcp
|
|
343
|
+
? await mcp.call(call.name, args)
|
|
344
|
+
: await runTool(call.name, args, {
|
|
345
|
+
onProgress: (info) => live.update({ ...info, window }),
|
|
346
|
+
});
|
|
347
|
+
} finally {
|
|
348
|
+
live.done();
|
|
349
|
+
}
|
|
301
350
|
}
|
|
302
351
|
|
|
303
352
|
// set_plan hands back a rendering of what the harness just stored; paying a
|
|
304
353
|
// second model call to paraphrase our own text would be pure waste.
|
|
305
|
-
|
|
354
|
+
// A sub-agent's report is already the distillation of everything it
|
|
355
|
+
// read; paying a second model call to summarise a summary is waste.
|
|
356
|
+
if (call.name !== 'set_plan' && !isTask) {
|
|
306
357
|
result = await maybeDistill(result, {
|
|
307
|
-
model, signal, command: args.cmd ?? describe(call.name, args),
|
|
358
|
+
model, signal, out, command: args.cmd ?? describe(call.name, args),
|
|
308
359
|
});
|
|
309
360
|
}
|
|
310
361
|
const failed = result.startsWith('error:');
|
|
311
|
-
if (failed) toolResultLines(result).forEach((l) =>
|
|
312
|
-
else if (call.name === 'set_plan') planLines().forEach((l) =>
|
|
313
|
-
else
|
|
362
|
+
if (failed) toolResultLines(result).forEach((l) => out(l));
|
|
363
|
+
else if (call.name === 'set_plan') planLines().forEach((l) => out(l));
|
|
364
|
+
else out(c.grey(` ✓ ${result.split('\n').length} lines`));
|
|
314
365
|
messages.push({ role: 'tool', tool_call_id: call.id, content: result });
|
|
315
366
|
}
|
|
316
367
|
|
|
317
368
|
// On the last tool result rather than in a message of its own, so the
|
|
318
369
|
// round only ever adds to the prompt. Nothing already sent is touched.
|
|
319
|
-
carryChecklist(messages);
|
|
370
|
+
if (usePlan) carryChecklist(messages);
|
|
320
371
|
// loop: the model now sees the tool output
|
|
321
372
|
}
|
|
322
373
|
}
|
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,
|