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 +143 -2
- package/package.json +1 -1
- package/src/agent.js +89 -48
- package/src/argv.js +2 -0
- package/src/client.js +36 -16
- package/src/compact.js +8 -5
- package/src/config.js +47 -2
- package/src/distill.js +4 -1
- package/src/index.js +74 -10
- package/src/subagent.js +218 -0
- package/src/tools.js +8 -1
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` |
|
|
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
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,
|
|
54
|
-
const res = await compact(messages,
|
|
55
|
-
if (res.failed || res.skipped) {
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
182
|
-
|
|
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 &&
|
|
204
|
+
if (config.autoCompact && window && totalUsage) {
|
|
186
205
|
const used = (totalUsage.prompt_tokens ?? 0) + (totalUsage.completion_tokens ?? 0);
|
|
187
|
-
if (used /
|
|
188
|
-
|
|
189
|
-
if (!await compactInto(messages, model, signal, auto))
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
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) =>
|
|
312
|
-
else if (call.name === 'set_plan') planLines().forEach((l) =>
|
|
313
|
-
else
|
|
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
|
|
81
|
-
* profile is overriding the model's own
|
|
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
|
-
|
|
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, {
|
|
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
|
-
|
|
85
|
-
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
config.
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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 ')) {
|
package/src/subagent.js
ADDED
|
@@ -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
|
-
|
|
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
|
}
|