@cruxy/cli 1.7.1 → 1.8.1
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 +9 -0
- package/dist/agent/permissions.js +72 -0
- package/dist/agent/session.js +30 -1
- package/dist/approval/classify.js +194 -44
- package/dist/approval/policy.js +142 -5
- package/dist/approval/prompt.js +52 -7
- package/dist/budget/session-budget.js +43 -7
- package/dist/checkpoint/capture.js +9 -12
- package/dist/checkpoint/coverage.js +70 -0
- package/dist/cli/command-catalog.js +5 -0
- package/dist/cli/commands/run.js +63 -21
- package/dist/cli/session-commands.js +59 -0
- package/dist/cli/session-factory.js +4 -0
- package/dist/indexing/walker.js +7 -2
- package/dist/limits/index.js +1 -1
- package/dist/limits/reduce.js +46 -4
- package/dist/mcp/bounds.js +21 -3
- package/dist/plan/policy.js +22 -6
- package/dist/render/permissions-view.js +88 -0
- package/dist/render/state.js +22 -3
- package/dist/session/index.js +3 -3
- package/dist/session/list.js +109 -23
- package/dist/session/log.js +28 -9
- package/dist/session/replay.js +71 -21
- package/dist/session/resume.js +74 -12
- package/dist/session/types.js +45 -0
- package/dist/subagent/orchestrator.js +157 -13
- package/dist/subagent/spawn-tool.js +30 -9
- package/dist/tools/index.js +1 -0
- package/dist/tools/schema-depth.js +67 -0
- package/dist/tui/index.js +1 -0
- package/dist/tui/limits-panel.js +6 -0
- package/dist/tui/panels.js +20 -1
- package/dist/tui/permissions-view.js +30 -0
- package/dist/tui/renderer.js +47 -15
- package/dist/usage/collect.js +3 -0
- package/dist/usage/types.js +10 -0
- package/package.json +1 -1
package/dist/session/replay.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { modeFromFlags } from "../agent/mode.js";
|
|
3
3
|
import { redactMessages } from "./redact.js";
|
|
4
|
-
import { SessionEventSchema, SessionMetaSchema, } from "./types.js";
|
|
4
|
+
import { KNOWN_EVENT_KINDS, SessionEventSchema, SessionMetaSchema, } from "./types.js";
|
|
5
5
|
/**
|
|
6
6
|
* Replay: fold an append-only event log back into the state a session needs to
|
|
7
7
|
* resume (P2).
|
|
@@ -11,8 +11,7 @@ import { SessionEventSchema, SessionMetaSchema, } from "./types.js";
|
|
|
11
11
|
* the array the model last saw, including the synthetic compaction summaries.
|
|
12
12
|
* Nothing is re-derived or re-summarised on the way back in.
|
|
13
13
|
*
|
|
14
|
-
* TOLERANCE IS THE POINT. A line that will not parse is SKIPPED
|
|
15
|
-
* never fatal:
|
|
14
|
+
* TOLERANCE IS THE POINT. A line that will not parse is SKIPPED, never fatal:
|
|
16
15
|
* - a torn final line from a crash mid-append would otherwise cost the whole
|
|
17
16
|
* conversation (this is the failure temp-then-rename buys off at write time;
|
|
18
17
|
* an append-only log buys it off here instead);
|
|
@@ -20,8 +19,25 @@ import { SessionEventSchema, SessionMetaSchema, } from "./types.js";
|
|
|
20
19
|
* heard of is skipped rather than rejected, which together with the
|
|
21
20
|
* `.passthrough()` schemas is what keeps two CLI versions able to share one
|
|
22
21
|
* home directory.
|
|
23
|
-
*
|
|
24
|
-
*
|
|
22
|
+
*
|
|
23
|
+
* THE TWO ARE COUNTED APART (#172 item 2), because they are not the same fact
|
|
24
|
+
* and the caller says something different about each. A damaged line means
|
|
25
|
+
* content was LOST — "the restored history may be incomplete" is warranted. An
|
|
26
|
+
* unknown kind means content is not visible HERE while the file is perfectly
|
|
27
|
+
* intact.
|
|
28
|
+
*
|
|
29
|
+
* Conflating them was survivable only while unknown kinds were rare. The
|
|
30
|
+
* `resumed` event is written on EVERY reopen, so an older build sharing a home
|
|
31
|
+
* directory would have announced possible history loss on every single resume —
|
|
32
|
+
* a forward-compatibility mechanism producing a corruption warning as routine
|
|
33
|
+
* output. `SessionState.skipped` now counts only damage;
|
|
34
|
+
* `SessionState.unknownEvents` counts the rest.
|
|
35
|
+
*
|
|
36
|
+
* This fixes readers from this build forward. An ALREADY-SHIPPED cruxy has the
|
|
37
|
+
* old reader and will still say "unreadable lines" when it meets a `resumed`
|
|
38
|
+
* event; nothing here can reach it. That is the cost of the split landing with
|
|
39
|
+
* the event rather than before it, and it is bounded — it misreports, it does
|
|
40
|
+
* not lose anything.
|
|
25
41
|
*/
|
|
26
42
|
/**
|
|
27
43
|
* The one cast in this module, isolated and explained.
|
|
@@ -36,20 +52,37 @@ import { SessionEventSchema, SessionMetaSchema, } from "./types.js";
|
|
|
36
52
|
function asMessages(validated) {
|
|
37
53
|
return validated;
|
|
38
54
|
}
|
|
39
|
-
/**
|
|
55
|
+
/**
|
|
56
|
+
* Classify one line.
|
|
57
|
+
*
|
|
58
|
+
* The distinction that matters is inside the failure case. A line that is valid
|
|
59
|
+
* JSON, is an object, and names a `kind` this build has never heard of is a
|
|
60
|
+
* NEWER cruxy's event — the file is intact and the forward-compatibility rule is
|
|
61
|
+
* working as designed. Anything else that fails is damage.
|
|
62
|
+
*
|
|
63
|
+
* A known kind with a payload that does not validate counts as DAMAGE, not as
|
|
64
|
+
* unknown: this build understands that kind, so failing to parse it means the
|
|
65
|
+
* line is wrong rather than merely new.
|
|
66
|
+
*/
|
|
40
67
|
function parseLine(line) {
|
|
41
68
|
const trimmed = line.trim();
|
|
42
69
|
if (trimmed === "")
|
|
43
|
-
return
|
|
70
|
+
return { outcome: "blank" };
|
|
44
71
|
let raw;
|
|
45
72
|
try {
|
|
46
73
|
raw = JSON.parse(trimmed);
|
|
47
74
|
}
|
|
48
75
|
catch {
|
|
49
|
-
return
|
|
76
|
+
return { outcome: "damaged" }; // torn mid-write
|
|
50
77
|
}
|
|
51
78
|
const parsed = SessionEventSchema.safeParse(raw);
|
|
52
|
-
|
|
79
|
+
if (parsed.success)
|
|
80
|
+
return { outcome: "event", event: parsed.data };
|
|
81
|
+
const kind = raw?.kind;
|
|
82
|
+
if (typeof kind === "string" && !KNOWN_EVENT_KINDS.has(kind)) {
|
|
83
|
+
return { outcome: "unknown" };
|
|
84
|
+
}
|
|
85
|
+
return { outcome: "damaged" };
|
|
53
86
|
}
|
|
54
87
|
/**
|
|
55
88
|
* Fold events into {@link SessionState}. Exported separately from file reading
|
|
@@ -60,7 +93,7 @@ function parseLine(line) {
|
|
|
60
93
|
* which directory the history refers to, and resuming a conversation whose
|
|
61
94
|
* origin is unknown is precisely what ruling 4 forbids.
|
|
62
95
|
*/
|
|
63
|
-
export function foldEvents(events,
|
|
96
|
+
export function foldEvents(events, counts = {}) {
|
|
64
97
|
const metaEvent = events.find((e) => e.kind === "meta");
|
|
65
98
|
if (!metaEvent) {
|
|
66
99
|
throw new Error("session log has no readable meta line");
|
|
@@ -76,6 +109,7 @@ export function foldEvents(events, skipped = 0) {
|
|
|
76
109
|
let planMode = false;
|
|
77
110
|
let mode = null;
|
|
78
111
|
let redactions = 0;
|
|
112
|
+
const resumes = [];
|
|
79
113
|
const usage = { input_tokens: 0, output_tokens: 0 };
|
|
80
114
|
for (const event of events) {
|
|
81
115
|
switch (event.kind) {
|
|
@@ -116,6 +150,12 @@ export function foldEvents(events, skipped = 0) {
|
|
|
116
150
|
messages = redactMessages(messages).messages;
|
|
117
151
|
redactions++;
|
|
118
152
|
break;
|
|
153
|
+
case "resumed":
|
|
154
|
+
// Recorded, never folded into anything the model sees. A reopen is a
|
|
155
|
+
// fact ABOUT the conversation, not a turn in it — the whole reason it
|
|
156
|
+
// could be added without touching `meta` or the message array.
|
|
157
|
+
resumes.push({ at: event.at, cwd: event.cwd });
|
|
158
|
+
break;
|
|
119
159
|
case "meta":
|
|
120
160
|
break;
|
|
121
161
|
}
|
|
@@ -128,7 +168,9 @@ export function foldEvents(events, skipped = 0) {
|
|
|
128
168
|
// Auto-approve reads false there, which is right: it was not a thing that
|
|
129
169
|
// could be on, so nothing is being inferred.
|
|
130
170
|
mode: mode ?? modeFromFlags(planMode, false),
|
|
131
|
-
skipped,
|
|
171
|
+
skipped: counts.skipped ?? 0,
|
|
172
|
+
unknownEvents: counts.unknownEvents ?? 0,
|
|
173
|
+
resumes,
|
|
132
174
|
redactions,
|
|
133
175
|
};
|
|
134
176
|
}
|
|
@@ -137,21 +179,29 @@ export function readEvents(file) {
|
|
|
137
179
|
const raw = readFileSync(file, "utf8");
|
|
138
180
|
const events = [];
|
|
139
181
|
let skipped = 0;
|
|
182
|
+
let unknownEvents = 0;
|
|
140
183
|
for (const line of raw.split("\n")) {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
184
|
+
const parsed = parseLine(line);
|
|
185
|
+
switch (parsed.outcome) {
|
|
186
|
+
case "event":
|
|
187
|
+
events.push(parsed.event);
|
|
188
|
+
break;
|
|
189
|
+
case "unknown":
|
|
190
|
+
unknownEvents++;
|
|
191
|
+
break;
|
|
192
|
+
case "damaged":
|
|
193
|
+
skipped++;
|
|
194
|
+
break;
|
|
195
|
+
case "blank":
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
148
198
|
}
|
|
149
|
-
return { events, skipped };
|
|
199
|
+
return { events, skipped, unknownEvents };
|
|
150
200
|
}
|
|
151
201
|
/** Read a session file and fold it into resumable state. */
|
|
152
202
|
export function replaySession(file) {
|
|
153
|
-
const { events, skipped } = readEvents(file);
|
|
154
|
-
return foldEvents(events, skipped);
|
|
203
|
+
const { events, skipped, unknownEvents } = readEvents(file);
|
|
204
|
+
return foldEvents(events, { skipped, unknownEvents });
|
|
155
205
|
}
|
|
156
206
|
/**
|
|
157
207
|
* Read ONLY the meta line — enough to list a session without folding its whole
|
package/dist/session/resume.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { selectList } from "../components/index.js";
|
|
2
2
|
import { usageError } from "../errors/index.js";
|
|
3
|
-
import {
|
|
3
|
+
import { listSessionRefs, listSessions, matchSessionRefs, summarizeSession, } from "./list.js";
|
|
4
4
|
import { replaySession } from "./replay.js";
|
|
5
5
|
/** How many sessions the bare-`--resume` picker offers. */
|
|
6
6
|
export const PICKER_LIMIT = 10;
|
|
@@ -47,6 +47,28 @@ export function cwdMismatchWarning(state, cwd) {
|
|
|
47
47
|
return (`this session was recorded in ${state.meta.cwd}, but you are in ${cwd} — ` +
|
|
48
48
|
`its history refers to files and paths from the original directory`);
|
|
49
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Directories this session has run in BEFORE, other than the one we are
|
|
52
|
+
* resuming into now (#172 item 2). Returns a warning to print, or null.
|
|
53
|
+
*
|
|
54
|
+
* Distinct from {@link cwdMismatchWarning}, which compares where the
|
|
55
|
+
* conversation BEGAN against where it is being resumed. This compares where it
|
|
56
|
+
* has since RUN. The two disagree in exactly the case that motivated the
|
|
57
|
+
* `resumed` event: begin in `/a`, resume in `/b`, then resume in `/a` again —
|
|
58
|
+
* `meta.cwd` matches, so the mismatch check is silent, and yet the history now
|
|
59
|
+
* contains a whole stretch of work done against a different tree.
|
|
60
|
+
*
|
|
61
|
+
* Nothing could say this before, because nothing recorded it. Sessions written
|
|
62
|
+
* before the event simply have no `resumes` and are silent here — absence is
|
|
63
|
+
* "not recorded", never "did not happen".
|
|
64
|
+
*/
|
|
65
|
+
export function priorDirectoriesWarning(state, cwd) {
|
|
66
|
+
const others = [...new Set(state.resumes.map((r) => r.cwd))].filter((dir) => dir !== cwd && dir !== state.meta.cwd);
|
|
67
|
+
if (others.length === 0)
|
|
68
|
+
return null;
|
|
69
|
+
return (`this session has also run in ${others.join(", ")} — ` +
|
|
70
|
+
`part of its history refers to files and paths from ${others.length === 1 ? "that directory" : "those directories"}`);
|
|
71
|
+
}
|
|
50
72
|
/**
|
|
51
73
|
* Load one session and collect its warnings. Throws a usage error when the
|
|
52
74
|
* file cannot be replayed at all (no meta line) — an unreadable session is
|
|
@@ -67,10 +89,23 @@ export function loadResume(session, cwd) {
|
|
|
67
89
|
const mismatch = cwdMismatchWarning(state, cwd);
|
|
68
90
|
if (mismatch)
|
|
69
91
|
warnings.push(mismatch);
|
|
92
|
+
const elsewhere = priorDirectoriesWarning(state, cwd);
|
|
93
|
+
if (elsewhere)
|
|
94
|
+
warnings.push(elsewhere);
|
|
70
95
|
if (state.skipped > 0) {
|
|
71
96
|
warnings.push(`${state.skipped} unreadable line${state.skipped === 1 ? "" : "s"} in the session log were skipped — ` +
|
|
72
97
|
`the restored history may be incomplete`);
|
|
73
98
|
}
|
|
99
|
+
if (state.unknownEvents > 0) {
|
|
100
|
+
// Deliberately NOT the sentence above. The file is intact; this build is
|
|
101
|
+
// simply older than whatever wrote those lines, and saying "unreadable"
|
|
102
|
+
// about a healthy log would send the user looking for damage that is not
|
|
103
|
+
// there. What IS true is that some of what the session recorded cannot be
|
|
104
|
+
// shown here.
|
|
105
|
+
warnings.push(`${state.unknownEvents} event${state.unknownEvents === 1 ? "" : "s"} in this session ` +
|
|
106
|
+
`${state.unknownEvents === 1 ? "was" : "were"} written by a newer cruxy and ${state.unknownEvents === 1 ? "is" : "are"} not shown — ` +
|
|
107
|
+
`the conversation itself is complete; upgrade to see the rest`);
|
|
108
|
+
}
|
|
74
109
|
if (state.redactions > 0) {
|
|
75
110
|
// Said on resume because the alternative is a user finding `[redacted …]`
|
|
76
111
|
// in a transcript and not knowing whether cruxy did it or the model wrote
|
|
@@ -82,27 +117,54 @@ export function loadResume(session, cwd) {
|
|
|
82
117
|
return { session, state, warnings };
|
|
83
118
|
}
|
|
84
119
|
/**
|
|
85
|
-
*
|
|
86
|
-
*
|
|
120
|
+
* VALIDATE `--resume <id>` — which session does this name? — without loading it.
|
|
121
|
+
*
|
|
122
|
+
* Split from the loading half for two reasons, one of them ordering:
|
|
123
|
+
*
|
|
124
|
+
* 1. `executeRun` has to answer "is this a real id?" BEFORE the non-TTY guard,
|
|
125
|
+
* so `cruxy --resume no-such-id < /dev/null` complains about the id rather
|
|
126
|
+
* than about the terminal. It must not have to pay a full `replaySession`
|
|
127
|
+
* to find that out — a VALID id in that same position is still going to be
|
|
128
|
+
* told it needs a terminal, and reading a whole conversation only to throw
|
|
129
|
+
* it away is exactly the cost this split avoids.
|
|
130
|
+
* 2. Both questions — ambiguous? which one? — are now asked of ONE index. This
|
|
131
|
+
* used to build the full listing up to three times (`isAmbiguous`, then
|
|
132
|
+
* `findSession`, then a third time to name the collisions), each one a
|
|
133
|
+
* complete read and parse of every session file in the project.
|
|
134
|
+
*
|
|
135
|
+
* Fails loud on an unknown or ambiguous id rather than silently starting a new
|
|
136
|
+
* session — the user named something specific.
|
|
137
|
+
*
|
|
138
|
+
* The one file that matched IS summarized, so the caller still gets the title
|
|
139
|
+
* and turn count the resume line prints. That is one read, not N.
|
|
87
140
|
*/
|
|
88
|
-
export function
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
.map((s) => shortId(s.sessionId));
|
|
141
|
+
export function resolveSessionId(cwd, id) {
|
|
142
|
+
const matches = matchSessionRefs(listSessionRefs(cwd), id);
|
|
143
|
+
if (matches.length > 1) {
|
|
144
|
+
const names = matches.map((m) => shortId(m.sessionId)).join(", ");
|
|
93
145
|
throw usageError(`\`${id}\` matches more than one session`, [
|
|
94
|
-
`did you mean one of: ${
|
|
146
|
+
`did you mean one of: ${names}?`,
|
|
95
147
|
"run `cruxy --resume` to pick from a list",
|
|
96
148
|
]);
|
|
97
149
|
}
|
|
98
|
-
const
|
|
99
|
-
if (!
|
|
150
|
+
const summary = matches.length === 1 ? summarizeSession(matches[0].file) : null;
|
|
151
|
+
if (!summary) {
|
|
100
152
|
throw usageError(`no session \`${id}\` in this project`, [
|
|
101
153
|
"run `cruxy --resume` to pick from recent sessions",
|
|
102
154
|
"sessions are per-directory; check you are in the right one",
|
|
103
155
|
]);
|
|
104
156
|
}
|
|
105
|
-
return
|
|
157
|
+
return summary;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Resolve `--resume <id>` all the way to restorable state: validate, then load.
|
|
161
|
+
*
|
|
162
|
+
* `executeRun` calls the two halves separately, so validation can precede the
|
|
163
|
+
* non-TTY guard; this composition is what everything else (and the tests) use
|
|
164
|
+
* where there is no ordering constraint to respect.
|
|
165
|
+
*/
|
|
166
|
+
export function resumeById(cwd, id) {
|
|
167
|
+
return loadResume(resolveSessionId(cwd, id), cwd);
|
|
106
168
|
}
|
|
107
169
|
/**
|
|
108
170
|
* Bare `--resume`: pick from the most recent sessions, or start a new one.
|
package/dist/session/types.js
CHANGED
|
@@ -248,6 +248,41 @@ export const RedactEventSchema = z
|
|
|
248
248
|
count: z.number().int().nonnegative().default(0),
|
|
249
249
|
})
|
|
250
250
|
.passthrough();
|
|
251
|
+
/**
|
|
252
|
+
* A session was REOPENED (#172 item 2) — `--resume` found this log and
|
|
253
|
+
* continued it.
|
|
254
|
+
*
|
|
255
|
+
* `meta` is written once and never again, on purpose: it records where and when
|
|
256
|
+
* the conversation BEGAN, and a second copy written from wherever it was
|
|
257
|
+
* resumed would make "the session's directory" ambiguous. That ruling stands.
|
|
258
|
+
* Its consequence was that a resume left no trace at all — a session started in
|
|
259
|
+
* one directory and continued in another looked, from the file, as though it
|
|
260
|
+
* had only ever run in the first.
|
|
261
|
+
*
|
|
262
|
+
* This event closes that without touching `meta`'s authority. `meta.cwd` is
|
|
263
|
+
* still where the conversation began; `resumed.cwd` is somewhere it has since
|
|
264
|
+
* run. A reader can tell the two apart because they are different kinds.
|
|
265
|
+
*
|
|
266
|
+
* SCOPE, stated because the obvious next step is deliberately NOT taken here:
|
|
267
|
+
* this makes a cross-directory resume AUDITABLE, not DISCOVERABLE. The file
|
|
268
|
+
* still lives under `projectKey(meta.cwd)`, and `listSessions` for the other
|
|
269
|
+
* directory does one `readdir` of its own project dir and will never see it.
|
|
270
|
+
* Surfacing it there needs a pointer written into the second directory, or a
|
|
271
|
+
* scan across every project — a separate decision with a real cost, not a
|
|
272
|
+
* side effect of recording the fact.
|
|
273
|
+
*/
|
|
274
|
+
export const ResumedEventSchema = z
|
|
275
|
+
.object({
|
|
276
|
+
kind: z.literal("resumed"),
|
|
277
|
+
at: z.string(),
|
|
278
|
+
/** The primary root the session was resumed INTO. */
|
|
279
|
+
cwd: z.string(),
|
|
280
|
+
/** Roots declared on the resuming run — multi-root can differ per run. */
|
|
281
|
+
roots: z.array(RootRefSchema).default([]),
|
|
282
|
+
/** The build that reopened it; a session can outlive several. */
|
|
283
|
+
cliVersion: z.string().optional(),
|
|
284
|
+
})
|
|
285
|
+
.passthrough();
|
|
251
286
|
/** Every event, discriminated on `kind`. */
|
|
252
287
|
export const SessionEventSchema = z.discriminatedUnion("kind", [
|
|
253
288
|
SessionMetaSchema,
|
|
@@ -258,4 +293,14 @@ export const SessionEventSchema = z.discriminatedUnion("kind", [
|
|
|
258
293
|
SessionModeEventSchema,
|
|
259
294
|
UsageEventSchema,
|
|
260
295
|
RedactEventSchema,
|
|
296
|
+
ResumedEventSchema,
|
|
261
297
|
]);
|
|
298
|
+
/**
|
|
299
|
+
* Every `kind` this build understands, derived from the union itself so the two
|
|
300
|
+
* cannot drift.
|
|
301
|
+
*
|
|
302
|
+
* This exists so the reader can tell "a line I do not understand" from "a line
|
|
303
|
+
* that is damaged" — see `replay.ts`. Those are different facts about a file and
|
|
304
|
+
* they used to be counted as one.
|
|
305
|
+
*/
|
|
306
|
+
export const KNOWN_EVENT_KINDS = new Set(SessionEventSchema.options.map((option) => option.shape.kind.value));
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { runAgent } from "../agent/loop.js";
|
|
3
|
-
import { CruxyError, ErrorCode, messageOf, sessionBudgetExhausted, subagentDepthExceeded, subagentScopeOverlap, } from "../errors/index.js";
|
|
4
|
+
import { classifyProviderError, CruxyError, ErrorCode, messageOf, sessionBudgetExhausted, subagentDepthExceeded, subagentScopeOverlap, } from "../errors/index.js";
|
|
5
|
+
import { UsageCollector } from "../usage/index.js";
|
|
4
6
|
import { UNRESOLVED_TIER, } from "../budget/index.js";
|
|
5
7
|
import { resolveTaskModel } from "../routing/index.js";
|
|
6
8
|
import { Workspace } from "../workspace/index.js";
|
|
@@ -52,10 +54,12 @@ export class SubagentOrchestrator {
|
|
|
52
54
|
* withheld at the cap, so this throw is the fail-loud backstop.
|
|
53
55
|
*
|
|
54
56
|
* Never rejects on a *child* failure — provider or tool crashes come back as
|
|
55
|
-
* `status: "failed"` for the parent to reason over. The
|
|
56
|
-
* do propagate: the depth cap (above)
|
|
57
|
+
* `status: "failed"` for the parent to reason over. The three exceptions that
|
|
58
|
+
* do propagate: the depth cap (above), `CRUXY_E_APPROVAL_REQUIRED`
|
|
57
59
|
* (non-interactive default-deny must reach the boundary, U.3 — a subagent is
|
|
58
|
-
* not a way to swallow it)
|
|
60
|
+
* not a way to swallow it), and `CRUXY_E_BUDGET_EXHAUSTED` (the weighted pool
|
|
61
|
+
* refused this child — see the catch block for why that one is not a partial
|
|
62
|
+
* result).
|
|
59
63
|
*/
|
|
60
64
|
async spawn(spec, parentDepth, opts = {}) {
|
|
61
65
|
const { deps } = this;
|
|
@@ -110,6 +114,57 @@ export class SubagentOrchestrator {
|
|
|
110
114
|
// parent's messages are never in scope here, and this array dies with the
|
|
111
115
|
// spawn — only the structured result below leaves this function.
|
|
112
116
|
const messages = [{ role: "user", content: spec.task }];
|
|
117
|
+
// Per-request usage for THIS child, collected exactly as the parent's turn
|
|
118
|
+
// collects its own (`agent/session.ts`) — the loop reports one entry per
|
|
119
|
+
// completed request, with the tier that served it. Two things need it and
|
|
120
|
+
// neither can be got from `AgentResult.usage`: the weighted-pool arithmetic
|
|
121
|
+
// is per-tier, and a run that THREW has no `AgentResult` at all while still
|
|
122
|
+
// having spent whatever it spent before it died.
|
|
123
|
+
const usage = new UsageCollector();
|
|
124
|
+
const startedAt = new Date().toISOString();
|
|
125
|
+
try {
|
|
126
|
+
return await this.runChild({
|
|
127
|
+
spec,
|
|
128
|
+
opts,
|
|
129
|
+
messages,
|
|
130
|
+
registry,
|
|
131
|
+
budget,
|
|
132
|
+
ctx,
|
|
133
|
+
artifacts,
|
|
134
|
+
label,
|
|
135
|
+
noun,
|
|
136
|
+
tag,
|
|
137
|
+
usage,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
// CHILD SPEND IS SESSION SPEND (cli#212). `budget.record` had exactly one
|
|
142
|
+
// call site — the parent's own turn — so a fan-out drew on the pool and
|
|
143
|
+
// left the numerator where it found it. The next admission check then
|
|
144
|
+
// divided by an allowance that had already been spent, which made the
|
|
145
|
+
// bound weakest at the moment it mattered most: the second fan-out of a
|
|
146
|
+
// session that had just dispatched three children.
|
|
147
|
+
//
|
|
148
|
+
// In the `finally` because the tokens are spent on every path out of here,
|
|
149
|
+
// including the two that throw. A child that died mid-run, or that a
|
|
150
|
+
// sibling's pool denial cancelled, still drew whatever it drew before it
|
|
151
|
+
// stopped, and the record is what it reported.
|
|
152
|
+
//
|
|
153
|
+
// NOT ALSO WRITTEN TO THE USAGE STORE. Child requests have never appeared
|
|
154
|
+
// in `/usage` and this does not change that — `/budget` now counts them
|
|
155
|
+
// and `/usage` still does not, so the two figures can differ by a
|
|
156
|
+
// fan-out's worth. That gap is real and pre-dates this; closing it means
|
|
157
|
+
// persisting child runs, which is a change to what is on disk and belongs
|
|
158
|
+
// in its own right rather than smuggled in behind an admission fix.
|
|
159
|
+
this.deps.budget?.record(usage.toRecord(randomUUID(), undefined, startedAt));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/** One child's run, from dispatch to structured result. Split out only so the
|
|
163
|
+
* usage fold above can be a `finally` over every path this can leave by. */
|
|
164
|
+
async runChild(args) {
|
|
165
|
+
const { deps } = this;
|
|
166
|
+
const { spec, opts, messages, registry, budget, ctx, artifacts } = args;
|
|
167
|
+
const { label, noun, tag, usage } = args;
|
|
113
168
|
let run;
|
|
114
169
|
try {
|
|
115
170
|
run = await runAgent({
|
|
@@ -128,6 +183,7 @@ export class SubagentOrchestrator {
|
|
|
128
183
|
router: deps.router,
|
|
129
184
|
taskClass: spec.taskClass ?? "subagent",
|
|
130
185
|
signal: opts.signal,
|
|
186
|
+
onRequestUsage: (req) => usage.record(req),
|
|
131
187
|
});
|
|
132
188
|
}
|
|
133
189
|
catch (err) {
|
|
@@ -136,6 +192,26 @@ export class SubagentOrchestrator {
|
|
|
136
192
|
deps.renderer?.setPhase(null);
|
|
137
193
|
throw err;
|
|
138
194
|
}
|
|
195
|
+
// THE WEIGHTED POOL REFUSED THIS CHILD (429 `budget_exhausted`), and that
|
|
196
|
+
// is not a per-child outcome (cli#212). The pool is one denominator shared
|
|
197
|
+
// by every sibling, every other surface on the account, and the parent's
|
|
198
|
+
// own next request: nothing that is about to run can succeed, so folding
|
|
199
|
+
// this into a `failed` result would let each sibling walk into its own
|
|
200
|
+
// refusal — N denials for one fact, each after its tokens were spent.
|
|
201
|
+
//
|
|
202
|
+
// Rethrown as the TYPED error rather than the raw one so `code`,
|
|
203
|
+
// `window`, `resetAt` and `miraAvailable` survive to the boundary. The
|
|
204
|
+
// last of those is the only step that unblocks the user now rather than
|
|
205
|
+
// telling them when to come back, and flattening it into a message string
|
|
206
|
+
// is exactly how it used to be lost.
|
|
207
|
+
const denial = poolDenial(err);
|
|
208
|
+
if (denial) {
|
|
209
|
+
if (deps.renderer) {
|
|
210
|
+
deps.renderer.note(`${deps.renderer.theme.glyph.failure} ${noun} stopped — ${denial.title}`);
|
|
211
|
+
}
|
|
212
|
+
deps.renderer?.setPhase(null);
|
|
213
|
+
throw denial;
|
|
214
|
+
}
|
|
139
215
|
if (deps.renderer) {
|
|
140
216
|
deps.renderer.note(`${deps.renderer.theme.glyph.failure} ${noun} failed: ${label}`);
|
|
141
217
|
}
|
|
@@ -146,13 +222,20 @@ export class SubagentOrchestrator {
|
|
|
146
222
|
...artifactsField(artifacts),
|
|
147
223
|
error: `${ErrorCode.SubagentFailed}: ${messageOf(err) ?? "unknown error"}`,
|
|
148
224
|
iterations: 0,
|
|
149
|
-
|
|
225
|
+
// WHAT IT ACTUALLY SPENT, which is not zero and was reported as zero
|
|
226
|
+
// until now. A child that failed on its third request had two requests'
|
|
227
|
+
// tokens deducted from a pool the parent then went on to fan out
|
|
228
|
+
// against; a fabricated 0 put that spend nowhere. When NOTHING reported
|
|
229
|
+
// usage — the request died before the provider said anything — the
|
|
230
|
+
// field is absent, which the parent reads as unknown. Zero is reserved
|
|
231
|
+
// for a child that genuinely drew nothing.
|
|
232
|
+
...usageField(usage),
|
|
150
233
|
};
|
|
151
234
|
}
|
|
152
235
|
deps.renderer?.setPhase(null);
|
|
153
236
|
const result = this.toResult(run, artifacts, label, noun);
|
|
154
237
|
deps.logger.debug(`${noun} ${result.status}: ${result.iterations} turn(s), tokens in/out ` +
|
|
155
|
-
`${result.usage
|
|
238
|
+
`${describeUsage(result.usage)} — ${label}`);
|
|
156
239
|
return result;
|
|
157
240
|
}
|
|
158
241
|
/**
|
|
@@ -168,11 +251,18 @@ export class SubagentOrchestrator {
|
|
|
168
251
|
*
|
|
169
252
|
* Cancellation: children share one {@link AbortController}. A child returning a
|
|
170
253
|
* `failed`/`budget-exceeded` result is a normal PARTIAL outcome — siblings run
|
|
171
|
-
* on. But a *fatal* throw from any child (non-interactive default-deny
|
|
172
|
-
* abort on `opts.signal` (Ctrl-C) aborts the
|
|
173
|
-
* its next turn boundary and its in-flight
|
|
174
|
-
* fan-out leaves no orphan. All children are
|
|
175
|
-
* throw propagates — never a detached,
|
|
254
|
+
* on. But a *fatal* throw from any child (non-interactive default-deny, or a
|
|
255
|
+
* weighted-pool denial) or an abort on `opts.signal` (Ctrl-C) aborts the
|
|
256
|
+
* controller: every sibling stops at its next turn boundary and its in-flight
|
|
257
|
+
* shell child is kill-tree'd, so the fan-out leaves no orphan. All children are
|
|
258
|
+
* awaited to settle before a fatal throw propagates — never a detached,
|
|
259
|
+
* still-running sibling.
|
|
260
|
+
*
|
|
261
|
+
* THE POOL DENIAL IS THE ONE WORTH NAMING (cli#212). `budget_exhausted` is a
|
|
262
|
+
* statement about a denominator every sibling shares, so the first 429 is the
|
|
263
|
+
* whole batch's answer: without the abort, each remaining child walks into its
|
|
264
|
+
* own refusal and the parent gets N reports of one fact — each one arriving
|
|
265
|
+
* after that child had already spent what it spent getting there.
|
|
176
266
|
*/
|
|
177
267
|
async spawnMany(specs, parentDepth, opts = {}) {
|
|
178
268
|
const { maxDepth } = this.deps.config.subagent;
|
|
@@ -230,8 +320,11 @@ export class SubagentOrchestrator {
|
|
|
230
320
|
});
|
|
231
321
|
}
|
|
232
322
|
catch (err) {
|
|
233
|
-
// A fatal throw (non-interactive default-deny
|
|
234
|
-
// fan-out — no sibling is left running —
|
|
323
|
+
// A fatal throw (non-interactive default-deny, or a weighted-pool
|
|
324
|
+
// denial) cancels the whole fan-out — no sibling is left running —
|
|
325
|
+
// then propagates. The abort is what makes the FIRST 429 the
|
|
326
|
+
// batch's answer: siblings stop at their next turn boundary rather
|
|
327
|
+
// than each spending its way into the same refusal.
|
|
235
328
|
controller.abort();
|
|
236
329
|
throw err;
|
|
237
330
|
}
|
|
@@ -396,6 +489,57 @@ function taskLabel(task) {
|
|
|
396
489
|
function isWriter(spec) {
|
|
397
490
|
return (spec.tools ?? []).some((t) => SUBAGENT_WRITE_TOOLS.has(t));
|
|
398
491
|
}
|
|
492
|
+
/**
|
|
493
|
+
* The typed pool denial behind an error, or `null` if it is not one.
|
|
494
|
+
*
|
|
495
|
+
* Two shapes reach here and both are the same fact: the raw SDK
|
|
496
|
+
* `BudgetExhaustedError` from this child's own request, and — when a child that
|
|
497
|
+
* itself spawned re-throws — the `CruxyError` a nested `spawn` already
|
|
498
|
+
* converted. Mapping goes through `classifyProviderError` so there is still ONE
|
|
499
|
+
* place that knows which SDK class means what.
|
|
500
|
+
*/
|
|
501
|
+
function poolDenial(err) {
|
|
502
|
+
if (CruxyError.is(err)) {
|
|
503
|
+
return err.code === ErrorCode.BudgetExhausted ? err : null;
|
|
504
|
+
}
|
|
505
|
+
const typed = classifyProviderError(err);
|
|
506
|
+
return typed?.code === ErrorCode.BudgetExhausted ? typed : null;
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* The tokens a child is KNOWN to have spent, or `undefined` when no request
|
|
510
|
+
* reported any.
|
|
511
|
+
*
|
|
512
|
+
* The loop fires `onRequestUsage` only for a request that COMPLETED, and when
|
|
513
|
+
* it does the provider's counts arrive as a pair or not at all — so an entry
|
|
514
|
+
* carries both figures or neither, and summing the ones that have them is a
|
|
515
|
+
* real measurement rather than a partial one. No entry at all is the genuinely
|
|
516
|
+
* unknown case: the request died before the provider reported anything, which
|
|
517
|
+
* does NOT mean the gateway metered nothing for it.
|
|
518
|
+
*/
|
|
519
|
+
function usageField(collected) {
|
|
520
|
+
const entries = collected.toRecord("", undefined, "").entries;
|
|
521
|
+
const total = sumReported(entries);
|
|
522
|
+
return total ? { usage: total } : {};
|
|
523
|
+
}
|
|
524
|
+
function sumReported(entries) {
|
|
525
|
+
let input = 0;
|
|
526
|
+
let output = 0;
|
|
527
|
+
let reported = false;
|
|
528
|
+
for (const e of entries) {
|
|
529
|
+
if (e.inputTokens === undefined && e.outputTokens === undefined)
|
|
530
|
+
continue;
|
|
531
|
+
input += e.inputTokens ?? 0;
|
|
532
|
+
output += e.outputTokens ?? 0;
|
|
533
|
+
reported = true;
|
|
534
|
+
}
|
|
535
|
+
return reported ? { input_tokens: input, output_tokens: output } : undefined;
|
|
536
|
+
}
|
|
537
|
+
/** `in/out`, or the word for an absent figure — never a stand-in `0/0`. */
|
|
538
|
+
function describeUsage(usage) {
|
|
539
|
+
return usage
|
|
540
|
+
? `${usage.input_tokens}/${usage.output_tokens}`
|
|
541
|
+
: "unknown (nothing reported)";
|
|
542
|
+
}
|
|
399
543
|
/** The honest result for a child cancelled before it could produce anything —
|
|
400
544
|
* used when a fatal sibling / Ctrl-C fired before this slot even dispatched. */
|
|
401
545
|
function cancelledResult() {
|
|
@@ -8,6 +8,26 @@ import { SPAWN_SUBAGENT_TOOL_NAME, SPAWN_SUBAGENTS_TOOL_NAME, } from "./registry
|
|
|
8
8
|
* on structurally encodes how much deeper nesting may go: at the configured
|
|
9
9
|
* cap the tool simply isn't registered.
|
|
10
10
|
*/
|
|
11
|
+
/**
|
|
12
|
+
* Errors a spawn tool must NOT convert into a tool result.
|
|
13
|
+
*
|
|
14
|
+
* A tool error is an invitation to the model to try again differently, so this
|
|
15
|
+
* set is exactly the errors for which trying again differently is not a thing
|
|
16
|
+
* the model can do:
|
|
17
|
+
*
|
|
18
|
+
* - `APPROVAL_REQUIRED` — non-interactive default-deny (U.3). There is no
|
|
19
|
+
* variation of the call that would be approved; the boundary reports it.
|
|
20
|
+
* - `BUDGET_EXHAUSTED` — the weighted pool refused (cli#212). Retrying, in any
|
|
21
|
+
* shape, is a request to a gate that will refuse again, and the parent's own
|
|
22
|
+
* next request would hit the same 429 regardless. Propagating it means the
|
|
23
|
+
* user reads what the gateway actually said — which window, when it recovers,
|
|
24
|
+
* and whether mira is still open — instead of the model narrating a tool
|
|
25
|
+
* failure and reaching for another spawn.
|
|
26
|
+
*/
|
|
27
|
+
const PROPAGATE = new Set([
|
|
28
|
+
ErrorCode.ApprovalRequired,
|
|
29
|
+
ErrorCode.BudgetExhausted,
|
|
30
|
+
]);
|
|
11
31
|
const parameters = z.object({
|
|
12
32
|
task: z
|
|
13
33
|
.string()
|
|
@@ -42,10 +62,12 @@ function resultPayload(result) {
|
|
|
42
62
|
...(result.artifacts ? { artifacts: result.artifacts } : {}),
|
|
43
63
|
...(result.error ? { error: result.error } : {}),
|
|
44
64
|
iterations: result.iterations,
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
65
|
+
// `"unknown"` rather than an omitted field or a `0/0` pair: the parent model
|
|
66
|
+
// reasons about what a child cost, and a missing key reads as nothing spent
|
|
67
|
+
// to anything skimming the payload. See `SubagentResult.usage`.
|
|
68
|
+
tokens: result.usage
|
|
69
|
+
? { input: result.usage.input_tokens, output: result.usage.output_tokens }
|
|
70
|
+
: "unknown",
|
|
49
71
|
};
|
|
50
72
|
}
|
|
51
73
|
/** The compact wire shape fed back to the parent model. */
|
|
@@ -84,10 +106,10 @@ export function makeSpawnSubagentTool(orchestrator, depth) {
|
|
|
84
106
|
}
|
|
85
107
|
catch (err) {
|
|
86
108
|
// Non-interactive default-deny propagates to the boundary (U.3) —
|
|
87
|
-
// same behavior as every other gated tool.
|
|
88
|
-
|
|
109
|
+
// same behavior as every other gated tool. So does a weighted-pool
|
|
110
|
+
// denial (cli#212): see `PROPAGATE`.
|
|
111
|
+
if (CruxyError.is(err) && PROPAGATE.has(err.code))
|
|
89
112
|
throw err;
|
|
90
|
-
}
|
|
91
113
|
// Depth-exceed and scope violations are the model's to correct: feed
|
|
92
114
|
// the coded, actionable message back as a tool error.
|
|
93
115
|
return { ok: false, error: toolErrorMessage(err) };
|
|
@@ -171,9 +193,8 @@ export function makeSpawnSubagentsTool(orchestrator, depth) {
|
|
|
171
193
|
}
|
|
172
194
|
catch (err) {
|
|
173
195
|
// Non-interactive default-deny propagates to the boundary (U.3).
|
|
174
|
-
if (CruxyError.is(err) && err.code
|
|
196
|
+
if (CruxyError.is(err) && PROPAGATE.has(err.code))
|
|
175
197
|
throw err;
|
|
176
|
-
}
|
|
177
198
|
// Scope overlap / depth-exceed are the model's to correct — coded error.
|
|
178
199
|
return { ok: false, error: toolErrorMessage(err) };
|
|
179
200
|
}
|