kritya 0.8.29-beta → 0.8.30-beta
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/dist/agent/loop.js +20 -0
- package/dist/agent/subagents.js +354 -0
- package/dist/index.js +26 -177
- package/dist/tools/subagent.js +15 -5
- package/dist/tools/writeAgent.js +16 -9
- package/package.json +1 -1
package/dist/agent/loop.js
CHANGED
|
@@ -28,6 +28,19 @@ export class Agent {
|
|
|
28
28
|
contextWindow = 120_000;
|
|
29
29
|
/** Max model round-trips per request before stopping to ask the user. */
|
|
30
30
|
maxSteps = DEFAULT_MAX_STEPS;
|
|
31
|
+
/**
|
|
32
|
+
* 1-based index of the step the loop is currently on, 0 before the first
|
|
33
|
+
* model call. Read by the subagent runner to report "step 4/15" as it works.
|
|
34
|
+
*/
|
|
35
|
+
stepIndex = 0;
|
|
36
|
+
/**
|
|
37
|
+
* True when the last run stopped because it reached `maxSteps` rather than
|
|
38
|
+
* finishing. That is the "wants to keep going" case, and it is the only
|
|
39
|
+
* signal that distinguishes it — the loop just ends either way, and the
|
|
40
|
+
* message it appends afterwards is indistinguishable from model output to
|
|
41
|
+
* anything that doesn't parse it.
|
|
42
|
+
*/
|
|
43
|
+
hitStepLimit = false;
|
|
31
44
|
/**
|
|
32
45
|
* How long one tool call may run before it's abandoned (config
|
|
33
46
|
* `toolTimeoutSeconds`). A tool with its own deadline opts out via
|
|
@@ -446,7 +459,13 @@ export class Agent {
|
|
|
446
459
|
}
|
|
447
460
|
}
|
|
448
461
|
async runLoop(systemMsg, handlers, signal) {
|
|
462
|
+
// Reset per run, not per Agent: the main agent serves many turns in one
|
|
463
|
+
// process, so a previous turn that hit the cap must not leave the flag set
|
|
464
|
+
// for the next one.
|
|
465
|
+
this.stepIndex = 0;
|
|
466
|
+
this.hitStepLimit = false;
|
|
449
467
|
for (let i = 0; i < this.maxSteps; i++) {
|
|
468
|
+
this.stepIndex = i + 1;
|
|
450
469
|
// Checked ahead of the generic abort so a kill mid-turn is reported as
|
|
451
470
|
// a kill rather than an ordinary cancellation.
|
|
452
471
|
this.kill.assertLive();
|
|
@@ -519,6 +538,7 @@ export class Agent {
|
|
|
519
538
|
await this.tryCompact("Auto-compacted context", handlers, signal);
|
|
520
539
|
}
|
|
521
540
|
}
|
|
541
|
+
this.hitStepLimit = true;
|
|
522
542
|
handlers.onAssistantText(`[Stopped after ${this.maxSteps} steps — the safety limit for one request. ` +
|
|
523
543
|
`Send "continue" to keep going, or raise "maxSteps" in ~/.kritya/config.json.]`);
|
|
524
544
|
}
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import { Agent } from "./loop.js";
|
|
2
|
+
import { commitWorktree, createWorktree, isGitRepo, removeWorktree, worktreeDiffStat, } from "./worktree.js";
|
|
3
|
+
import { PermissionManager } from "../permissions/permissions.js";
|
|
4
|
+
import { SessionStore } from "../session/store.js";
|
|
5
|
+
/**
|
|
6
|
+
* Batches are capped at six tasks by spawn_agent's own schema and at four by
|
|
7
|
+
* spawn_write_agent's, so allowing more concurrency than the larger of the two
|
|
8
|
+
* buys nothing — the extra slots can never be filled.
|
|
9
|
+
*/
|
|
10
|
+
export const MAX_SUBAGENT_CONCURRENCY = 6;
|
|
11
|
+
export const DEFAULT_SUBAGENT_CONCURRENCY = 3;
|
|
12
|
+
export const DEFAULT_SUBAGENT_TIMEOUT_MS = 10 * 60 * 1000;
|
|
13
|
+
/** Read-only agents get half the budget of write agents: they search, they don't build. */
|
|
14
|
+
export const DEFAULT_READ_SUBAGENT_STEPS = 15;
|
|
15
|
+
export const DEFAULT_WRITE_SUBAGENT_STEPS = 30;
|
|
16
|
+
/** Longest headline kept for the one-line progress report. */
|
|
17
|
+
const HEADLINE_MAX = 72;
|
|
18
|
+
/** A finite value floors to itself, bounded to 1..MAX_SUBAGENT_CONCURRENCY; anything else takes the default. */
|
|
19
|
+
export function clampConcurrency(value) {
|
|
20
|
+
const wanted = typeof value === "number" && Number.isFinite(value)
|
|
21
|
+
? Math.floor(value)
|
|
22
|
+
: DEFAULT_SUBAGENT_CONCURRENCY;
|
|
23
|
+
return Math.min(Math.max(wanted, 1), MAX_SUBAGENT_CONCURRENCY);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Why an aborted subagent stopped, or undefined if it wasn't aborted.
|
|
27
|
+
*
|
|
28
|
+
* The raw abort message is identical for both cases ("this operation was
|
|
29
|
+
* aborted"), so without this a ten-minute wall-clock cap reads exactly like the
|
|
30
|
+
* user pressing Ctrl-C.
|
|
31
|
+
*/
|
|
32
|
+
export function abortOutcome(opts) {
|
|
33
|
+
if (opts.timedOut)
|
|
34
|
+
return { error: "timed out before finishing", stoppedEarly: "timeout" };
|
|
35
|
+
if (opts.cancelled)
|
|
36
|
+
return { error: "cancelled", stoppedEarly: "cancelled" };
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
/** Joins the non-empty reasons a subagent's work could not be used, or undefined if there are none. */
|
|
40
|
+
export function mergeErrors(...messages) {
|
|
41
|
+
const kept = messages.filter((m) => typeof m === "string" && m.trim() !== "");
|
|
42
|
+
return kept.length > 0 ? kept.join(" ") : undefined;
|
|
43
|
+
}
|
|
44
|
+
/** One collapsed line from a finished result, for the progress line. */
|
|
45
|
+
export function headline(result) {
|
|
46
|
+
const text = (result.error ?? result.summary).replace(/\s+/g, " ").trim();
|
|
47
|
+
if (text === "")
|
|
48
|
+
return "no findings";
|
|
49
|
+
return text.length > HEADLINE_MAX ? `${text.slice(0, HEADLINE_MAX - 1)}…` : text;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The bracketed note the spawn_* tools append to a result, or undefined for a
|
|
53
|
+
* clean run.
|
|
54
|
+
*
|
|
55
|
+
* Shared by both tools so a failure reads the same either way, and so the
|
|
56
|
+
* single-task path can't lose it: that path returns the summary bare, which
|
|
57
|
+
* used to mean a lone failing subagent came back as an empty string with the
|
|
58
|
+
* reason nowhere in sight.
|
|
59
|
+
*/
|
|
60
|
+
export function subagentStatusNote(result) {
|
|
61
|
+
const notes = [];
|
|
62
|
+
if (result.error)
|
|
63
|
+
notes.push(`[error: ${result.error}]`);
|
|
64
|
+
if (result.stoppedEarly === "max-steps") {
|
|
65
|
+
notes.push("[stopped early: it hit its step limit, so the findings above are incomplete — " +
|
|
66
|
+
"re-dispatch a narrower task, or raise subagentMaxSteps]");
|
|
67
|
+
}
|
|
68
|
+
else if (result.stoppedEarly) {
|
|
69
|
+
notes.push(`[stopped early: ${result.stoppedEarly}]`);
|
|
70
|
+
}
|
|
71
|
+
return notes.length > 0 ? notes.join("\n") : undefined;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Handlers for a subagent's own agent loop: no streaming to a UI, but two
|
|
75
|
+
* things this file has to get right.
|
|
76
|
+
*
|
|
77
|
+
* Assistant text is accumulated rather than replaced. The loop emits one
|
|
78
|
+
* message per step, so the old `onAssistantText: (t) => (finalText = t)` kept
|
|
79
|
+
* only the last one — a subagent that reported findings and then carried on
|
|
80
|
+
* working silently lost them, and the summary came back as whatever it said
|
|
81
|
+
* last.
|
|
82
|
+
*
|
|
83
|
+
* And progress is reported from onToolStart rather than onTextDelta: deltas
|
|
84
|
+
* arrive per token, which would churn the parent's status line for no gain,
|
|
85
|
+
* while tool starts are the natural "here's what it's doing now" beat.
|
|
86
|
+
*/
|
|
87
|
+
export function createSubagentHandlers(opts) {
|
|
88
|
+
const parts = [];
|
|
89
|
+
return {
|
|
90
|
+
onTextDelta: () => { },
|
|
91
|
+
onReasoningDelta: () => { },
|
|
92
|
+
onAssistantText: (text) => {
|
|
93
|
+
const trimmed = text.trim();
|
|
94
|
+
if (trimmed === "")
|
|
95
|
+
return;
|
|
96
|
+
parts.push(trimmed);
|
|
97
|
+
opts.onFinalText(parts.join("\n\n"));
|
|
98
|
+
},
|
|
99
|
+
onToolStart: (_id, name, summary) => {
|
|
100
|
+
if (!opts.onStep)
|
|
101
|
+
return;
|
|
102
|
+
const label = summary.trim() || name;
|
|
103
|
+
const info = opts.stepInfo?.();
|
|
104
|
+
opts.onStep(info && info.max > 0 ? `step ${info.step}/${info.max} · ${label}` : label);
|
|
105
|
+
},
|
|
106
|
+
onToolEnd: () => { },
|
|
107
|
+
requestPermission: opts.requestPermission,
|
|
108
|
+
onUsage: () => { },
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Builds the `spawnAgents` implementation the spawn_agent / spawn_write_agent
|
|
113
|
+
* tools call through `ToolContext`.
|
|
114
|
+
*
|
|
115
|
+
* Lives here rather than inline in the CLI entry point so the headless and
|
|
116
|
+
* Electron entry points can wire the same runner instead of going without
|
|
117
|
+
* subagents entirely, and so the logic is reachable from tests — `dist/index.js`
|
|
118
|
+
* is excluded from coverage, which is how the swallowed-failure and
|
|
119
|
+
* timeout-looks-like-cancel bugs survived in it.
|
|
120
|
+
*/
|
|
121
|
+
export function createSubagentRunner(deps) {
|
|
122
|
+
const concurrency = clampConcurrency(deps.concurrency);
|
|
123
|
+
const timeoutMs = deps.timeoutMs ?? DEFAULT_SUBAGENT_TIMEOUT_MS;
|
|
124
|
+
const readMaxSteps = deps.readMaxSteps ?? DEFAULT_READ_SUBAGENT_STEPS;
|
|
125
|
+
const writeMaxSteps = deps.writeMaxSteps ?? DEFAULT_WRITE_SUBAGENT_STEPS;
|
|
126
|
+
/** Fields every subagent Agent shares, so the two runners can't drift apart. */
|
|
127
|
+
function baseAgent(tools, maxSteps, task, spanAttributes) {
|
|
128
|
+
const sub = new Agent(deps.client, deps.model, tools, {
|
|
129
|
+
workspace: deps.workspace,
|
|
130
|
+
sandboxMode: deps.sandboxMode,
|
|
131
|
+
trustWorkspace: deps.trustWorkspace,
|
|
132
|
+
}, new PermissionManager([], deps.workspace), new SessionStore(deps.workspace, true), []);
|
|
133
|
+
sub.maxSteps = maxSteps;
|
|
134
|
+
sub.audit = deps.audit;
|
|
135
|
+
// Both have non-optional NOOP defaults, so only overwrite when supplied.
|
|
136
|
+
if (deps.tracer)
|
|
137
|
+
sub.tracer = deps.tracer;
|
|
138
|
+
if (deps.meter)
|
|
139
|
+
sub.meter = deps.meter;
|
|
140
|
+
sub.kill = deps.kill();
|
|
141
|
+
sub.spanParent = deps.spanParent?.();
|
|
142
|
+
sub.spanAttributes = { ...spanAttributes, "kritya.subagent_task": task.slice(0, 120) };
|
|
143
|
+
return sub;
|
|
144
|
+
}
|
|
145
|
+
async function runReadOnlyAgent(task, signal, onProgress) {
|
|
146
|
+
const sub = baseAgent(deps.readOnlyTools, readMaxSteps, task, { "kritya.subagent": true });
|
|
147
|
+
let finalText = "";
|
|
148
|
+
await sub.runTurn(task, createSubagentHandlers({
|
|
149
|
+
onFinalText: (t) => (finalText = t),
|
|
150
|
+
// read-only tools never require permission, so this is never invoked
|
|
151
|
+
requestPermission: async () => "no",
|
|
152
|
+
onStep: onProgress,
|
|
153
|
+
stepInfo: () => ({ step: sub.stepIndex, max: sub.maxSteps }),
|
|
154
|
+
}), signal);
|
|
155
|
+
return {
|
|
156
|
+
task,
|
|
157
|
+
write: false,
|
|
158
|
+
summary: finalText.trim() || "(subagent returned no findings)",
|
|
159
|
+
// Hitting the cap is the "wants to keep going" case: the findings are
|
|
160
|
+
// real but incomplete, unlike a timeout, which is infrastructure.
|
|
161
|
+
...(sub.hitStepLimit ? { stoppedEarly: "max-steps" } : {}),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
async function runWriteAgent(task, signal, onProgress) {
|
|
165
|
+
if (!isGitRepo(deps.workspace)) {
|
|
166
|
+
return {
|
|
167
|
+
task,
|
|
168
|
+
write: true,
|
|
169
|
+
summary: "",
|
|
170
|
+
error: "the workspace is not a git repository, so an isolated worktree could not be created",
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
const wt = createWorktree(deps.workspace);
|
|
174
|
+
if (!wt) {
|
|
175
|
+
deps.audit?.logTool({
|
|
176
|
+
tool: "subagent_worktree",
|
|
177
|
+
summary: `worktree creation failed for task: ${task.slice(0, 72)}`,
|
|
178
|
+
outcome: "error",
|
|
179
|
+
});
|
|
180
|
+
return { task, write: true, summary: "", error: "failed to create an isolated git worktree" };
|
|
181
|
+
}
|
|
182
|
+
deps.audit?.logTool({
|
|
183
|
+
tool: "subagent_worktree",
|
|
184
|
+
summary: `created branch "${wt.branch}" for task: ${task.slice(0, 72)}`,
|
|
185
|
+
outcome: "ok",
|
|
186
|
+
});
|
|
187
|
+
let finalText = "";
|
|
188
|
+
// A subagent that died mid-run must not come back looking like a success:
|
|
189
|
+
// the tool renders `[error: …]` only when this is set, so without it a
|
|
190
|
+
// crash was reported as an ordinary summary that merely happened to begin
|
|
191
|
+
// with "(subagent stopped: …)" — and its partial edits were still committed.
|
|
192
|
+
let runError;
|
|
193
|
+
let stoppedEarly;
|
|
194
|
+
try {
|
|
195
|
+
// Auto-allow ordinary writes/edits/shell (no human is watching this run),
|
|
196
|
+
// but a destructive command still forces `warning` via classifyDanger in
|
|
197
|
+
// the agent loop regardless of the allowlist — fail-safe deny it there,
|
|
198
|
+
// since there's no one to confirm it and letting it run unattended would
|
|
199
|
+
// be unsafe even inside an isolated worktree (it still has real shell/
|
|
200
|
+
// network access).
|
|
201
|
+
const sub = new Agent(deps.client, deps.model, deps.writeTools, {
|
|
202
|
+
workspace: wt.dir,
|
|
203
|
+
sandboxMode: deps.sandboxMode,
|
|
204
|
+
trustWorkspace: deps.trustWorkspace,
|
|
205
|
+
}, new PermissionManager({ allow: ["write_file", "edit_file", "shell(*)"], deny: [] }, wt.dir), new SessionStore(wt.dir, true), []);
|
|
206
|
+
sub.maxSteps = writeMaxSteps;
|
|
207
|
+
// No human is watching this run (see the auto-allow comment above) —
|
|
208
|
+
// the forced unsandboxed-fallback warning must resolve on its own via
|
|
209
|
+
// the handler below rather than be raised at all.
|
|
210
|
+
sub.interactive = false;
|
|
211
|
+
sub.audit = deps.audit;
|
|
212
|
+
if (deps.tracer)
|
|
213
|
+
sub.tracer = deps.tracer;
|
|
214
|
+
if (deps.meter)
|
|
215
|
+
sub.meter = deps.meter;
|
|
216
|
+
sub.kill = deps.kill(); // see runReadOnlyAgent
|
|
217
|
+
sub.spanParent = deps.spanParent?.();
|
|
218
|
+
sub.spanAttributes = {
|
|
219
|
+
"kritya.subagent": true,
|
|
220
|
+
"kritya.subagent_write": true,
|
|
221
|
+
"kritya.subagent_task": task.slice(0, 120),
|
|
222
|
+
"kritya.subagent_branch": wt.branch,
|
|
223
|
+
};
|
|
224
|
+
await sub.runTurn(task, createSubagentHandlers({
|
|
225
|
+
onFinalText: (t) => (finalText = t),
|
|
226
|
+
requestPermission: async (_name, _summary, _diff, warning) => (warning ? "no" : "yes"),
|
|
227
|
+
onStep: onProgress,
|
|
228
|
+
stepInfo: () => ({ step: sub.stepIndex, max: sub.maxSteps }),
|
|
229
|
+
}), signal);
|
|
230
|
+
if (sub.hitStepLimit)
|
|
231
|
+
stoppedEarly = "max-steps";
|
|
232
|
+
}
|
|
233
|
+
catch (err) {
|
|
234
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
235
|
+
runError = `the subagent stopped before finishing: ${message}`;
|
|
236
|
+
if (!finalText)
|
|
237
|
+
finalText = `(subagent stopped: ${message})`;
|
|
238
|
+
}
|
|
239
|
+
const commitState = commitWorktree(wt, `kritya subagent: ${task.slice(0, 72)}`);
|
|
240
|
+
deps.audit?.logTool({
|
|
241
|
+
tool: "subagent_worktree",
|
|
242
|
+
summary: `branch "${wt.branch}": commit ${commitState}`,
|
|
243
|
+
outcome: commitState === "failed" ? "error" : "ok",
|
|
244
|
+
});
|
|
245
|
+
if (commitState === "clean") {
|
|
246
|
+
const cleaned = removeWorktree(deps.workspace, wt, true);
|
|
247
|
+
const summary = finalText.trim() || "(no changes made)";
|
|
248
|
+
// Surface this rather than silently leave an empty orphaned branch: a
|
|
249
|
+
// failed `git branch -D` (e.g. a transient ref lock) shouldn't look
|
|
250
|
+
// identical to a subagent that genuinely made no changes.
|
|
251
|
+
return {
|
|
252
|
+
task,
|
|
253
|
+
write: true,
|
|
254
|
+
summary,
|
|
255
|
+
error: mergeErrors(runError, cleaned
|
|
256
|
+
? undefined
|
|
257
|
+
: `made no changes, but its empty scratch branch "${wt.branch}" could not be ` +
|
|
258
|
+
`auto-deleted (a transient git lock) — safe to remove manually with ` +
|
|
259
|
+
`\`git branch -D ${wt.branch}\``),
|
|
260
|
+
...(stoppedEarly ? { stoppedEarly } : {}),
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
if (commitState === "failed") {
|
|
264
|
+
// Don't discard the worktree: the subagent's edits are real work, even
|
|
265
|
+
// if a commit hook rejected them. Leave it on disk for manual recovery.
|
|
266
|
+
return {
|
|
267
|
+
task,
|
|
268
|
+
write: true,
|
|
269
|
+
summary: finalText.trim(),
|
|
270
|
+
error: mergeErrors(runError, "changes could not be committed (a commit hook may have rejected them) — " +
|
|
271
|
+
`left uncommitted at ${wt.dir}`),
|
|
272
|
+
...(stoppedEarly ? { stoppedEarly } : {}),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
const diffstat = worktreeDiffStat(deps.workspace, wt);
|
|
276
|
+
removeWorktree(deps.workspace, wt, false);
|
|
277
|
+
return {
|
|
278
|
+
task,
|
|
279
|
+
write: true,
|
|
280
|
+
branch: wt.branch,
|
|
281
|
+
summary: `${finalText.trim() || "(no summary)"}${diffstat ? `\n\n${diffstat}` : ""}`,
|
|
282
|
+
error: runError,
|
|
283
|
+
...(stoppedEarly ? { stoppedEarly } : {}),
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
async function runOneAgent(spec, parentSignal, onProgress) {
|
|
287
|
+
const controller = new AbortController();
|
|
288
|
+
const onParentAbort = () => controller.abort();
|
|
289
|
+
parentSignal?.addEventListener("abort", onParentAbort);
|
|
290
|
+
let timedOut = false;
|
|
291
|
+
const timer = setTimeout(() => {
|
|
292
|
+
timedOut = true;
|
|
293
|
+
controller.abort();
|
|
294
|
+
}, timeoutMs);
|
|
295
|
+
try {
|
|
296
|
+
const result = spec.write
|
|
297
|
+
? await runWriteAgent(spec.task, controller.signal, onProgress)
|
|
298
|
+
: await runReadOnlyAgent(spec.task, controller.signal, onProgress);
|
|
299
|
+
if (result.error === undefined && result.stoppedEarly === undefined)
|
|
300
|
+
return result;
|
|
301
|
+
const aborted = abortOutcome({ timedOut, cancelled: parentSignal?.aborted === true });
|
|
302
|
+
// A step-cap stop is the agent asking for more room, not an abort, so
|
|
303
|
+
// only overwrite when this run was actually cut short.
|
|
304
|
+
if (aborted && (timedOut || parentSignal?.aborted === true))
|
|
305
|
+
return { ...result, ...aborted };
|
|
306
|
+
return result;
|
|
307
|
+
}
|
|
308
|
+
catch (err) {
|
|
309
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
310
|
+
const aborted = abortOutcome({ timedOut, cancelled: parentSignal?.aborted === true });
|
|
311
|
+
return {
|
|
312
|
+
task: spec.task,
|
|
313
|
+
write: Boolean(spec.write),
|
|
314
|
+
summary: "",
|
|
315
|
+
error: aborted?.error ?? message,
|
|
316
|
+
...(aborted ? { stoppedEarly: aborted.stoppedEarly } : {}),
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
finally {
|
|
320
|
+
clearTimeout(timer);
|
|
321
|
+
parentSignal?.removeEventListener("abort", onParentAbort);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return {
|
|
325
|
+
// Runs subagents concurrently, capped at `concurrency` at a time, so a
|
|
326
|
+
// burst of parallel tasks can't exhaust API rate limits or system resources.
|
|
327
|
+
async spawnAgents(specs, signal, onProgress) {
|
|
328
|
+
// Don't stand up worktrees and API calls for work that the shared kill
|
|
329
|
+
// switch would abort on its first step anyway.
|
|
330
|
+
if (deps.kill().active) {
|
|
331
|
+
return specs.map((s) => ({
|
|
332
|
+
task: s.task,
|
|
333
|
+
write: Boolean(s.write),
|
|
334
|
+
summary: "",
|
|
335
|
+
error: "not started — the kill switch is active",
|
|
336
|
+
}));
|
|
337
|
+
}
|
|
338
|
+
const results = new Array(specs.length);
|
|
339
|
+
let next = 0;
|
|
340
|
+
const workers = Array.from({ length: Math.min(concurrency, specs.length) }, async () => {
|
|
341
|
+
while (next < specs.length) {
|
|
342
|
+
const i = next++;
|
|
343
|
+
results[i] = await runOneAgent(specs[i], signal, (text) => onProgress?.(i, text));
|
|
344
|
+
// Reported the moment it lands rather than only once the slowest
|
|
345
|
+
// sibling finishes: a six-way batch is otherwise invisible for as
|
|
346
|
+
// long as its slowest member takes.
|
|
347
|
+
onProgress?.(i, `done — ${headline(results[i])}`);
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
await Promise.all(workers);
|
|
351
|
+
return results;
|
|
352
|
+
},
|
|
353
|
+
};
|
|
354
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -40,7 +40,7 @@ import { isBannerSeen, markBannerSeen } from "./trust/bannerSeen.js";
|
|
|
40
40
|
import { partitionByTrust, serverFingerprint, trustServer } from "./trust/mcpTrust.js";
|
|
41
41
|
import { runHeadless } from "./headless.js";
|
|
42
42
|
import { installCrashHandlers } from "./crash.js";
|
|
43
|
-
import {
|
|
43
|
+
import { createSubagentRunner } from "./agent/subagents.js";
|
|
44
44
|
import { VERSION } from "./version.js";
|
|
45
45
|
import { updateNoticeForUser } from "./update/check.js";
|
|
46
46
|
const USAGE = `kritya — a lean, provider-agnostic terminal coding agent
|
|
@@ -546,182 +546,31 @@ async function main() {
|
|
|
546
546
|
...nonRecursive(ALL_TOOLS),
|
|
547
547
|
...mcpTools.filter((t) => !t.requiresPermission),
|
|
548
548
|
];
|
|
549
|
-
//
|
|
550
|
-
//
|
|
551
|
-
//
|
|
552
|
-
|
|
553
|
-
const
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
sub.spanParent = agent.turnSpan;
|
|
575
|
-
sub.spanAttributes = { "kritya.subagent": true, "kritya.subagent_task": task.slice(0, 120) };
|
|
576
|
-
let finalText = "";
|
|
577
|
-
await sub.runTurn(task,
|
|
578
|
-
// read-only tools never require permission, so this is never invoked
|
|
579
|
-
silentHandlers((t) => (finalText = t), async () => "no"), signal);
|
|
580
|
-
return { task, write: false, summary: finalText.trim() || "(subagent returned no findings)" };
|
|
581
|
-
}
|
|
582
|
-
async function runWriteAgent(task, signal) {
|
|
583
|
-
if (!isGitRepo(workspace)) {
|
|
584
|
-
return {
|
|
585
|
-
task,
|
|
586
|
-
write: true,
|
|
587
|
-
summary: "",
|
|
588
|
-
error: "the workspace is not a git repository, so an isolated worktree could not be created",
|
|
589
|
-
};
|
|
590
|
-
}
|
|
591
|
-
const wt = createWorktree(workspace);
|
|
592
|
-
if (!wt) {
|
|
593
|
-
sessionAudit?.logTool({
|
|
594
|
-
tool: "subagent_worktree",
|
|
595
|
-
summary: `worktree creation failed for task: ${task.slice(0, 72)}`,
|
|
596
|
-
outcome: "error",
|
|
597
|
-
});
|
|
598
|
-
return { task, write: true, summary: "", error: "failed to create an isolated git worktree" };
|
|
599
|
-
}
|
|
600
|
-
sessionAudit?.logTool({
|
|
601
|
-
tool: "subagent_worktree",
|
|
602
|
-
summary: `created branch "${wt.branch}" for task: ${task.slice(0, 72)}`,
|
|
603
|
-
outcome: "ok",
|
|
604
|
-
});
|
|
605
|
-
let finalText = "";
|
|
606
|
-
try {
|
|
607
|
-
// Auto-allow ordinary writes/edits/shell (no human is watching this run),
|
|
608
|
-
// but a destructive command still forces `warning` via classifyDanger in
|
|
609
|
-
// the agent loop regardless of the allowlist — fail-safe deny it there,
|
|
610
|
-
// since there's no one to confirm it and letting it run unattended would
|
|
611
|
-
// be unsafe even inside an isolated worktree (it still has real shell/
|
|
612
|
-
// network access).
|
|
613
|
-
const sub = new Agent(client, () => modelRef.current, writeSubTools, { workspace: wt.dir, sandboxMode, trustWorkspace }, new PermissionManager({ allow: ["write_file", "edit_file", "shell(*)"], deny: [] }, wt.dir), new SessionStore(wt.dir, true), []);
|
|
614
|
-
sub.maxSteps = 30;
|
|
615
|
-
// No human is watching this run (see the auto-allow comment above) —
|
|
616
|
-
// the forced unsandboxed-fallback warning must resolve on its own via
|
|
617
|
-
// the handler below rather than be raised at all.
|
|
618
|
-
sub.interactive = false;
|
|
619
|
-
sub.audit = sessionAudit;
|
|
620
|
-
sub.tracer = sessionTracer;
|
|
621
|
-
sub.meter = sessionMeter;
|
|
622
|
-
sub.kill = agent.kill; // see runReadOnlyAgent
|
|
623
|
-
sub.spanParent = agent.turnSpan;
|
|
624
|
-
sub.spanAttributes = {
|
|
625
|
-
"kritya.subagent": true,
|
|
626
|
-
"kritya.subagent_write": true,
|
|
627
|
-
"kritya.subagent_task": task.slice(0, 120),
|
|
628
|
-
"kritya.subagent_branch": wt.branch,
|
|
629
|
-
};
|
|
630
|
-
await sub.runTurn(task, silentHandlers((t) => (finalText = t), async (_name, _summary, _diff, warning) => (warning ? "no" : "yes")), signal);
|
|
631
|
-
}
|
|
632
|
-
catch (err) {
|
|
633
|
-
if (!finalText)
|
|
634
|
-
finalText = `(subagent stopped: ${err instanceof Error ? err.message : String(err)})`;
|
|
635
|
-
}
|
|
636
|
-
const commitState = commitWorktree(wt, `kritya subagent: ${task.slice(0, 72)}`);
|
|
637
|
-
sessionAudit?.logTool({
|
|
638
|
-
tool: "subagent_worktree",
|
|
639
|
-
summary: `branch "${wt.branch}": commit ${commitState}`,
|
|
640
|
-
outcome: commitState === "failed" ? "error" : "ok",
|
|
641
|
-
});
|
|
642
|
-
if (commitState === "clean") {
|
|
643
|
-
const cleaned = removeWorktree(workspace, wt, true);
|
|
644
|
-
const summary = finalText.trim() || "(no changes made)";
|
|
645
|
-
// Surface this rather than silently leave an empty orphaned branch: a
|
|
646
|
-
// failed `git branch -D` (e.g. a transient ref lock) shouldn't look
|
|
647
|
-
// identical to a subagent that genuinely made no changes.
|
|
648
|
-
return cleaned
|
|
649
|
-
? { task, write: true, summary }
|
|
650
|
-
: {
|
|
651
|
-
task,
|
|
652
|
-
write: true,
|
|
653
|
-
summary,
|
|
654
|
-
error: `made no changes, but its empty scratch branch "${wt.branch}" could not be ` +
|
|
655
|
-
`auto-deleted (a transient git lock) — safe to remove manually with ` +
|
|
656
|
-
`\`git branch -D ${wt.branch}\``,
|
|
657
|
-
};
|
|
658
|
-
}
|
|
659
|
-
if (commitState === "failed") {
|
|
660
|
-
// Don't discard the worktree: the subagent's edits are real work, even
|
|
661
|
-
// if a commit hook rejected them. Leave it on disk for manual recovery.
|
|
662
|
-
return {
|
|
663
|
-
task,
|
|
664
|
-
write: true,
|
|
665
|
-
summary: finalText.trim(),
|
|
666
|
-
error: `changes could not be committed (a commit hook may have rejected them) — left uncommitted at ${wt.dir}`,
|
|
667
|
-
};
|
|
668
|
-
}
|
|
669
|
-
const diffstat = worktreeDiffStat(workspace, wt);
|
|
670
|
-
removeWorktree(workspace, wt, false);
|
|
671
|
-
return {
|
|
672
|
-
task,
|
|
673
|
-
write: true,
|
|
674
|
-
branch: wt.branch,
|
|
675
|
-
summary: `${finalText.trim() || "(no summary)"}${diffstat ? `\n\n${diffstat}` : ""}`,
|
|
676
|
-
};
|
|
677
|
-
}
|
|
678
|
-
async function runOneAgent(spec, parentSignal) {
|
|
679
|
-
const controller = new AbortController();
|
|
680
|
-
const onParentAbort = () => controller.abort();
|
|
681
|
-
parentSignal?.addEventListener("abort", onParentAbort);
|
|
682
|
-
const timer = setTimeout(() => controller.abort(), SUBAGENT_TIMEOUT_MS);
|
|
683
|
-
try {
|
|
684
|
-
return spec.write
|
|
685
|
-
? await runWriteAgent(spec.task, controller.signal)
|
|
686
|
-
: await runReadOnlyAgent(spec.task, controller.signal);
|
|
687
|
-
}
|
|
688
|
-
catch (err) {
|
|
689
|
-
return {
|
|
690
|
-
task: spec.task,
|
|
691
|
-
write: Boolean(spec.write),
|
|
692
|
-
summary: "",
|
|
693
|
-
error: err instanceof Error ? err.message : String(err),
|
|
694
|
-
};
|
|
695
|
-
}
|
|
696
|
-
finally {
|
|
697
|
-
clearTimeout(timer);
|
|
698
|
-
parentSignal?.removeEventListener("abort", onParentAbort);
|
|
699
|
-
}
|
|
700
|
-
}
|
|
701
|
-
// Runs subagents concurrently, capped at SUBAGENT_CONCURRENCY at a time, so
|
|
702
|
-
// a burst of parallel tasks can't exhaust API rate limits or system resources.
|
|
703
|
-
const spawnAgents = async (specs, signal) => {
|
|
704
|
-
// Don't stand up worktrees and API calls for work that the shared kill
|
|
705
|
-
// switch would abort on its first step anyway.
|
|
706
|
-
if (agent.kill.active) {
|
|
707
|
-
return specs.map((s) => ({
|
|
708
|
-
task: s.task,
|
|
709
|
-
write: Boolean(s.write),
|
|
710
|
-
summary: "",
|
|
711
|
-
error: "not started — the kill switch is active",
|
|
712
|
-
}));
|
|
713
|
-
}
|
|
714
|
-
const results = new Array(specs.length);
|
|
715
|
-
let next = 0;
|
|
716
|
-
const workers = Array.from({ length: Math.min(SUBAGENT_CONCURRENCY, specs.length) }, async () => {
|
|
717
|
-
while (next < specs.length) {
|
|
718
|
-
const i = next++;
|
|
719
|
-
results[i] = await runOneAgent(specs[i], signal);
|
|
720
|
-
}
|
|
721
|
-
});
|
|
722
|
-
await Promise.all(workers);
|
|
723
|
-
return results;
|
|
724
|
-
};
|
|
549
|
+
// The runner itself lives in agent/subagents.ts. It used to be inline here,
|
|
550
|
+
// which put it out of reach of both the headless and Electron entry points
|
|
551
|
+
// (neither of which wires subagents at all as a result) and out of reach of
|
|
552
|
+
// the test suite, since dist/index.js is excluded from coverage.
|
|
553
|
+
const { spawnAgents } = createSubagentRunner({
|
|
554
|
+
client,
|
|
555
|
+
model: () => modelRef.current,
|
|
556
|
+
readOnlyTools: readOnlySubTools,
|
|
557
|
+
writeTools: writeSubTools,
|
|
558
|
+
workspace,
|
|
559
|
+
sandboxMode,
|
|
560
|
+
trustWorkspace,
|
|
561
|
+
audit: sessionAudit,
|
|
562
|
+
tracer: sessionTracer,
|
|
563
|
+
meter: sessionMeter,
|
|
564
|
+
// Read lazily: this runner is constructed before `agent` exists below.
|
|
565
|
+
kill: () => agent.kill,
|
|
566
|
+
spanParent: () => agent.turnSpan,
|
|
567
|
+
concurrency: config.subagentConcurrency,
|
|
568
|
+
timeoutMs: config.subagentTimeoutSeconds === undefined
|
|
569
|
+
? undefined
|
|
570
|
+
: config.subagentTimeoutSeconds * 1000,
|
|
571
|
+
readMaxSteps: config.subagentMaxSteps,
|
|
572
|
+
writeMaxSteps: config.subagentWriteMaxSteps,
|
|
573
|
+
});
|
|
725
574
|
const agent = new Agent(client, () => modelRef.current, tools, {
|
|
726
575
|
workspace,
|
|
727
576
|
sandboxMode,
|
package/dist/tools/subagent.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { subagentStatusNote } from "../agent/subagents.js";
|
|
1
2
|
const MAX_AGENTS = 6;
|
|
2
3
|
/**
|
|
3
4
|
* Dispatches one or more focused, read-only subtasks to fresh agents that each
|
|
@@ -31,7 +32,7 @@ export const spawnAgentTool = {
|
|
|
31
32
|
// Self-managed: each subagent has its own wall-clock cap and the batch runs
|
|
32
33
|
// several in sequence, so the total legitimately exceeds any per-tool limit.
|
|
33
34
|
timeoutMs: 0,
|
|
34
|
-
async execute(args, ctx, signal) {
|
|
35
|
+
async execute(args, ctx, signal, onProgress) {
|
|
35
36
|
const tasks = Array.isArray(args.tasks)
|
|
36
37
|
? args.tasks.map((t) => String(t).trim()).filter(Boolean)
|
|
37
38
|
: [];
|
|
@@ -41,11 +42,20 @@ export const spawnAgentTool = {
|
|
|
41
42
|
return `Error: at most ${MAX_AGENTS} tasks per call.`;
|
|
42
43
|
if (!ctx.spawnAgents)
|
|
43
44
|
return "Error: subagents are not available in this session.";
|
|
44
|
-
const results = await ctx.spawnAgents(tasks.map((task) => ({ task, write: false })), signal
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
const results = await ctx.spawnAgents(tasks.map((task) => ({ task, write: false })), signal,
|
|
46
|
+
// The runner reports per-subagent status; the batch is what knows how
|
|
47
|
+
// many there are, so the "2/6" framing is added here.
|
|
48
|
+
(index, text) => onProgress?.(`subagent ${index + 1}/${tasks.length} · ${text}`));
|
|
49
|
+
if (results.length === 1) {
|
|
50
|
+
const note = subagentStatusNote(results[0]);
|
|
51
|
+
return note ? `${results[0].summary}\n\n${note}` : results[0].summary;
|
|
52
|
+
}
|
|
47
53
|
return results
|
|
48
|
-
.map((r, i) =>
|
|
54
|
+
.map((r, i) => {
|
|
55
|
+
const note = subagentStatusNote(r);
|
|
56
|
+
const header = `--- Subagent ${i + 1}: ${r.task.slice(0, 60)} ---`;
|
|
57
|
+
return note ? `${header}\n${r.summary}\n${note}` : `${header}\n${r.summary}`;
|
|
58
|
+
})
|
|
49
59
|
.join("\n\n");
|
|
50
60
|
},
|
|
51
61
|
summarize: (args) => {
|
package/dist/tools/writeAgent.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { subagentStatusNote } from "../agent/subagents.js";
|
|
1
2
|
const MAX_AGENTS = 4;
|
|
2
3
|
/**
|
|
3
4
|
* Dispatches one or more subagents that can write files, edit files, and run
|
|
@@ -45,7 +46,7 @@ export const spawnWriteAgentTool = {
|
|
|
45
46
|
tasks.map((t, i) => ` ${i + 1}. ${String(t).slice(0, 100)}`).join("\n") +
|
|
46
47
|
`\n\nNone of this touches your working tree directly — review each branch's diff before merging.`);
|
|
47
48
|
},
|
|
48
|
-
async execute(args, ctx, signal) {
|
|
49
|
+
async execute(args, ctx, signal, onProgress) {
|
|
49
50
|
const tasks = Array.isArray(args.tasks)
|
|
50
51
|
? args.tasks.map((t) => String(t).trim()).filter(Boolean)
|
|
51
52
|
: [];
|
|
@@ -55,17 +56,23 @@ export const spawnWriteAgentTool = {
|
|
|
55
56
|
return `Error: at most ${MAX_AGENTS} tasks per call.`;
|
|
56
57
|
if (!ctx.spawnAgents)
|
|
57
58
|
return "Error: subagents are not available in this session.";
|
|
58
|
-
const results = await ctx.spawnAgents(tasks.map((task) => ({ task, write: true })), signal
|
|
59
|
+
const results = await ctx.spawnAgents(tasks.map((task) => ({ task, write: true })), signal,
|
|
60
|
+
// See subagent.ts — the batch owns the "2/4" framing, the runner reports status.
|
|
61
|
+
(index, text) => onProgress?.(`subagent ${index + 1}/${tasks.length} · ${text}`));
|
|
59
62
|
return results
|
|
60
63
|
.map((r, i) => {
|
|
61
64
|
const header = `--- Write subagent ${i + 1}: ${r.task.slice(0, 60)} ---`;
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
65
|
+
const note = subagentStatusNote(r);
|
|
66
|
+
// A problem replaces the branch note rather than sitting next to it:
|
|
67
|
+
// telling someone to review a branch is wrong advice when the commit
|
|
68
|
+
// failed, or when the subagent never finished what it was doing.
|
|
69
|
+
const tail = note
|
|
70
|
+
? `\n${note}`
|
|
71
|
+
: r.branch
|
|
72
|
+
? `\n\nChanges committed to branch "${r.branch}". Review with ` +
|
|
73
|
+
`\`git diff main...${r.branch}\` (or your base branch), merge with \`git merge ${r.branch}\`.`
|
|
74
|
+
: "\n\n(no file changes were made)";
|
|
75
|
+
return `${header}\n${r.summary}${tail}`;
|
|
69
76
|
})
|
|
70
77
|
.join("\n\n");
|
|
71
78
|
},
|