@cruxy/cli 1.7.1 → 1.8.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/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 +6 -1
- 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/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/limits/reduce.js
CHANGED
|
@@ -144,15 +144,26 @@ export function reduceLimits(res, readAt = Date.now()) {
|
|
|
144
144
|
readAt,
|
|
145
145
|
};
|
|
146
146
|
}
|
|
147
|
+
/**
|
|
148
|
+
* TWO SELECTORS, TWO QUESTIONS. {@link bindingWindow} answers *which window will
|
|
149
|
+
* stop you first*; {@link scarcestWindow} answers *how much may you actually
|
|
150
|
+
* spend right now*. They are not the same question and they do not always pick
|
|
151
|
+
* the same window, so merging them would make one of the two answers wrong —
|
|
152
|
+
* see each function for which.
|
|
153
|
+
*/
|
|
147
154
|
/**
|
|
148
155
|
* The window that will stop this user FIRST — the one the gate itself calls
|
|
149
156
|
* binding: whichever of monthly/burst has the smaller REMAINING fraction
|
|
150
157
|
* (`internal/budget/decide.go`).
|
|
151
158
|
*
|
|
152
|
-
* This is what earns the single bar the rail has room for. Drawing
|
|
153
|
-
* because it is the bigger number would routinely show a comfortable
|
|
154
|
-
* the trailing-12h window — a quarter of the month's cap on every
|
|
155
|
-
* tier — is the one about to refuse the next request.
|
|
159
|
+
* FOR DISPLAY. This is what earns the single bar the rail has room for. Drawing
|
|
160
|
+
* the month because it is the bigger number would routinely show a comfortable
|
|
161
|
+
* 6% while the trailing-12h window — a quarter of the month's cap on every
|
|
162
|
+
* self-serve tier — is the one about to refuse the next request.
|
|
163
|
+
*
|
|
164
|
+
* The fraction is right HERE because a bar is a fraction: the pixels state
|
|
165
|
+
* proportion, so the window drawn must be the one whose proportion is the
|
|
166
|
+
* warning. It is wrong for admission, and {@link scarcestWindow} says why.
|
|
156
167
|
*/
|
|
157
168
|
export function bindingWindow(monthly, burst) {
|
|
158
169
|
if (!monthly)
|
|
@@ -164,6 +175,37 @@ export function bindingWindow(monthly, burst) {
|
|
|
164
175
|
? { window: burst, name: "burst" }
|
|
165
176
|
: { window: monthly, name: "month" };
|
|
166
177
|
}
|
|
178
|
+
/**
|
|
179
|
+
* The window with the least ABSOLUTE remaining — the ceiling an admission check
|
|
180
|
+
* must respect (cli#212).
|
|
181
|
+
*
|
|
182
|
+
* FOR ADMISSION. BOTH windows gate every request, so a draw of D is admitted
|
|
183
|
+
* only when `D ≤ monthly.remaining` AND `D ≤ burst.remaining` — which makes the
|
|
184
|
+
* spendable allowance exactly `min(remaining)`, a number of tokens rather than
|
|
185
|
+
* a proportion.
|
|
186
|
+
*
|
|
187
|
+
* WHY {@link bindingWindow} CANNOT SERVE HERE, concretely: a Core user 5% into
|
|
188
|
+
* the month (700k of 14M left) whose burst is at 14% (500k of 3.5M left). The
|
|
189
|
+
* fraction rule names the month, correctly — 5% is the figure worth showing.
|
|
190
|
+
* But handing 700k to an admission check authorises a batch the burst will
|
|
191
|
+
* refuse at 500k, and it refuses it AFTER the tokens are spent, against a
|
|
192
|
+
* sliding window that refills only by trickle over twelve hours. The two rules
|
|
193
|
+
* disagree exactly when the caps differ by 4×, which is to say on every
|
|
194
|
+
* self-serve tier, in the ordinary case rather than a corner one.
|
|
195
|
+
*
|
|
196
|
+
* The error is one-directional, which is why it is worth two functions: erring
|
|
197
|
+
* toward the smaller remainder narrows a fan-out that might have fitted, and
|
|
198
|
+
* erring toward the larger one spends a window that cannot be un-spent.
|
|
199
|
+
*/
|
|
200
|
+
export function scarcestWindow(monthly, burst) {
|
|
201
|
+
if (!monthly)
|
|
202
|
+
return burst ? { window: burst, name: "burst" } : undefined;
|
|
203
|
+
if (!burst)
|
|
204
|
+
return { window: monthly, name: "month" };
|
|
205
|
+
return burst.remaining < monthly.remaining
|
|
206
|
+
? { window: burst, name: "burst" }
|
|
207
|
+
: { window: monthly, name: "month" };
|
|
208
|
+
}
|
|
167
209
|
/** The fraction of a cap consumed, clamped to [0,1] for display. */
|
|
168
210
|
export function usedFraction(w) {
|
|
169
211
|
if (!usableCap(w.cap))
|
package/dist/mcp/bounds.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { MAX_SCHEMA_DEPTH, schemaDepth } from "../tools/schema-depth.js";
|
|
1
2
|
const PERMISSIVE_SCHEMA = {
|
|
2
3
|
type: "object",
|
|
3
4
|
additionalProperties: true,
|
|
@@ -5,9 +6,16 @@ const PERMISSIVE_SCHEMA = {
|
|
|
5
6
|
/**
|
|
6
7
|
* Apply the bounds to a raw `tools/list`. Over the count cap → keep the first N
|
|
7
8
|
* (by advertised order) and report `droppedCount`. Per tool: an over-long
|
|
8
|
-
* description is truncated with a marker; an over-size input schema
|
|
9
|
-
* with a permissive `object` schema and a note (we never forward an
|
|
10
|
-
* schema, but we also never claim the args are
|
|
9
|
+
* description is truncated with a marker; an over-size OR over-deep input schema
|
|
10
|
+
* is replaced with a permissive `object` schema and a note (we never forward an
|
|
11
|
+
* unbounded or unsendable schema, but we also never claim the args are
|
|
12
|
+
* constrained when we dropped it).
|
|
13
|
+
*
|
|
14
|
+
* Degrading beats dying, and it is the same trade the byte cap already makes: an
|
|
15
|
+
* over-deep tool stays callable with unconstrained args and a visible note,
|
|
16
|
+
* rather than taking the session down with it. Nothing is lost locally by the
|
|
17
|
+
* swap — the adapter delegates arg validation to the server either way
|
|
18
|
+
* (`PASSTHROUGH`), and the approval gate is untouched.
|
|
11
19
|
*/
|
|
12
20
|
export function boundToolList(tools, bounds) {
|
|
13
21
|
const kept = tools.slice(0, bounds.maxTools);
|
|
@@ -30,6 +38,16 @@ export function boundToolList(tools, bounds) {
|
|
|
30
38
|
inputSchema = { ...PERMISSIVE_SCHEMA };
|
|
31
39
|
notes.push(`input schema (${schemaBytes} bytes) exceeded the ${bounds.maxSchemaBytes}-byte cap and was replaced with a permissive one`);
|
|
32
40
|
}
|
|
41
|
+
// ORDER MATTERS: depth is measured on the POST-byte-cap value. A schema the
|
|
42
|
+
// byte cap already replaced is permissive (depth 1) and passes trivially, so
|
|
43
|
+
// the only schema this counter ever walks is one that fit `maxSchemaBytes` —
|
|
44
|
+
// which is what bounds the walk. Measuring the raw schema first would hand a
|
|
45
|
+
// hostile server an unbounded one.
|
|
46
|
+
const depth = schemaDepth(inputSchema);
|
|
47
|
+
if (depth >= MAX_SCHEMA_DEPTH) {
|
|
48
|
+
inputSchema = { ...PERMISSIVE_SCHEMA };
|
|
49
|
+
notes.push(`input schema nests ${depth} levels deep, at or over the ${MAX_SCHEMA_DEPTH}-level provider limit, and was replaced with a permissive one`);
|
|
50
|
+
}
|
|
33
51
|
return { name: t.name, description, inputSchema, notes };
|
|
34
52
|
});
|
|
35
53
|
return { tools: bounded, droppedCount };
|
package/dist/plan/policy.js
CHANGED
|
@@ -5,11 +5,23 @@
|
|
|
5
5
|
* for the rest of the run, recording the real scope in the shared
|
|
6
6
|
* {@link SessionAllowlist} so U.3's own scoping (`scopeCovers`) governs reuse.
|
|
7
7
|
*
|
|
8
|
-
* What it deliberately does NOT do — the
|
|
8
|
+
* What it deliberately does NOT do — the boundary, in three parts:
|
|
9
9
|
* • **destructive** actions always fall through to the base policy (prompt);
|
|
10
10
|
* • **ungrantable** actions (`scope:"none"`) always fall through (prompt),
|
|
11
11
|
* even if the tier is `mutate` — because `SessionAllowlist.grant` is a no-op
|
|
12
|
-
* on `none`, an ungrantable action can never be pre-approved
|
|
12
|
+
* on `none`, an ungrantable action can never be pre-approved;
|
|
13
|
+
* • **irreversible** actions always fall through (prompt), even if the tier is
|
|
14
|
+
* `mutate` AND the scope is grantable (cli#193). This is the only one of the
|
|
15
|
+
* three that `tier` cannot express: a write to `/etc/passwd` is tier
|
|
16
|
+
* `mutate` with a perfectly good exact-file scope, and the checkpoint still
|
|
17
|
+
* cannot restore it. Without this clause, "approve + allow safe steps"
|
|
18
|
+
* silently authorized exactly that for the rest of the run.
|
|
19
|
+
*
|
|
20
|
+
* That last clause is why it is checked HERE rather than left to the allowlist.
|
|
21
|
+
* The ceiling in `SessionAllowlist.allows` covers grants being *spent*; this
|
|
22
|
+
* branch never asks `allows()` — it is pre-consent, so it *writes* a grant. It
|
|
23
|
+
* is the one path into the allowlist that the chokepoint cannot defend, so it
|
|
24
|
+
* carries its own copy of the same rule.
|
|
13
25
|
*
|
|
14
26
|
* So approving a plan (even with "allow safe steps") never consents to a
|
|
15
27
|
* destructive or irreversible action; those re-confirm at execution time.
|
|
@@ -27,14 +39,18 @@ export class PlanExecutionPolicy {
|
|
|
27
39
|
this.safeStepGrants = true;
|
|
28
40
|
}
|
|
29
41
|
async decide(request) {
|
|
30
|
-
// Already covered by a prior session grant (scoped, tier-keyed
|
|
42
|
+
// Already covered by a prior session grant (scoped, tier-keyed, and — since
|
|
43
|
+
// cli#193 — reversible; `allows` enforces the ceiling itself) → allow.
|
|
31
44
|
if (this.allowlist.allows(request))
|
|
32
45
|
return { allow: true };
|
|
33
|
-
// The one plan-mode shortcut: a grantable mutate action,
|
|
34
|
-
// the "allow safe steps" choice. Record its concrete scope,
|
|
46
|
+
// The one plan-mode shortcut: a grantable, REVERSIBLE mutate action,
|
|
47
|
+
// pre-consented by the "allow safe steps" choice. Record its concrete scope,
|
|
48
|
+
// then allow. `!request.irreversible` is load-bearing, not defensive — see
|
|
49
|
+
// the third bullet in this class's doc comment.
|
|
35
50
|
if (this.safeStepGrants &&
|
|
36
51
|
request.tier === "mutate" &&
|
|
37
|
-
request.scope.kind !== "none"
|
|
52
|
+
request.scope.kind !== "none" &&
|
|
53
|
+
!request.irreversible) {
|
|
38
54
|
this.allowlist.grant(request);
|
|
39
55
|
return { allow: true };
|
|
40
56
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { fit, reflow, visibleWidth } from "./layout.js";
|
|
2
|
+
/**
|
|
3
|
+
* The ceiling, in one line.
|
|
4
|
+
*
|
|
5
|
+
* Stated as an ABSOLUTE, because since cli#193 it is one: `allows()` checks
|
|
6
|
+
* `irreversible` before it matches any scope, so there is no longer a "unless
|
|
7
|
+
* you granted it" clause to add. #196 asked this screen to be honest about the
|
|
8
|
+
* asymmetry; the honest version today is that the asymmetry is closed, and the
|
|
9
|
+
* cost — a granted `git` not covering `git commit` — is what the inert marks
|
|
10
|
+
* and the re-prompt copy explain.
|
|
11
|
+
*/
|
|
12
|
+
const CEILING = "irreversible actions always ask — in every mode, and no session grant outranks it";
|
|
13
|
+
/**
|
|
14
|
+
* How wide the label column may grow. A grant label is usually short (`git
|
|
15
|
+
* commands`, `changes under src/`), but an exact-file grant or a test grant
|
|
16
|
+
* carries a path or a whole command, and one long row must not cost every other
|
|
17
|
+
* row its tier and root.
|
|
18
|
+
*/
|
|
19
|
+
const LABEL_MAX_COLS = 34;
|
|
20
|
+
/** `key value`, on the same gutter `/status` uses so the two screens align. */
|
|
21
|
+
function row(key, value, t) {
|
|
22
|
+
return ` ${t.muted(key.padEnd(11))} ${value}`;
|
|
23
|
+
}
|
|
24
|
+
/** `2 standing · 1 inert`, or what an empty/unwired list has to say instead. */
|
|
25
|
+
function grantSummary(grants, t) {
|
|
26
|
+
if (!grants)
|
|
27
|
+
return t.muted("not tracked in this session");
|
|
28
|
+
if (grants.length === 0) {
|
|
29
|
+
return t.muted("none — every action has been decided one at a time");
|
|
30
|
+
}
|
|
31
|
+
const dead = grants.filter((g) => g.dead).length;
|
|
32
|
+
const live = grants.length - dead;
|
|
33
|
+
const head = `${live} standing`;
|
|
34
|
+
return dead === 0
|
|
35
|
+
? head
|
|
36
|
+
: `${head} ${t.warning(`${t.glyph.sep} ${dead} inert`)}`;
|
|
37
|
+
}
|
|
38
|
+
/** The full `/permissions` block as lines to print. */
|
|
39
|
+
export function permissionsReportLines(report, t, width = Infinity) {
|
|
40
|
+
const lines = [t.heading("permissions")];
|
|
41
|
+
lines.push(row("mode", `${t.strong(report.mode)} ${t.muted(`— ${report.modeDescription}`)}`, t));
|
|
42
|
+
// The ceiling is the one row that is the same in every session, and it is here
|
|
43
|
+
// for exactly that reason: a screen listing what runs without asking has to
|
|
44
|
+
// state what asks anyway, next to it, or the list reads as the whole answer.
|
|
45
|
+
lines.push(row("ceiling", report.autoApproves ? t.warning(CEILING) : t.muted(CEILING), t));
|
|
46
|
+
lines.push(row("grants", grantSummary(report.grants, t), t));
|
|
47
|
+
if (report.grants && report.grants.length > 0) {
|
|
48
|
+
lines.push("");
|
|
49
|
+
// One label column, sized to the longest label the list actually holds and
|
|
50
|
+
// capped so a pathological grant (a long exact path, a long test command)
|
|
51
|
+
// cannot push the tier and the root off a normal terminal. Padded on the
|
|
52
|
+
// VISIBLE width, because a coloured label carries escape bytes that
|
|
53
|
+
// `padEnd` would count and the terminal would not.
|
|
54
|
+
const labelCols = Math.min(LABEL_MAX_COLS, Math.max(...report.grants.map((g) => visibleWidth(g.label))));
|
|
55
|
+
for (const g of report.grants) {
|
|
56
|
+
const n = t.muted(`${g.n}`.padStart(2));
|
|
57
|
+
const text = fit(g.label, labelCols, t.glyph.ellipsis);
|
|
58
|
+
const pad = " ".repeat(Math.max(0, labelCols - visibleWidth(text)));
|
|
59
|
+
const label = g.dead ? t.muted(text) : t.strong(text);
|
|
60
|
+
lines.push(` ${n} ${label}${pad} ${t.muted(`${g.tier} ${t.glyph.sep} ${g.where}`)}`);
|
|
61
|
+
if (!g.dead)
|
|
62
|
+
continue;
|
|
63
|
+
// Why it can never match, on its own rows so the reason survives a narrow
|
|
64
|
+
// terminal intact — an inert grant the user cannot read the reason for is
|
|
65
|
+
// the same puzzle as an unmarked one.
|
|
66
|
+
const indent = " ";
|
|
67
|
+
const room = Number.isFinite(width)
|
|
68
|
+
? Math.max(20, width - indent.length)
|
|
69
|
+
: Infinity;
|
|
70
|
+
const reason = `inert — ${g.dead}`;
|
|
71
|
+
const wrapped = Number.isFinite(room) ? reflow(reason, room) : [reason];
|
|
72
|
+
for (const line of wrapped)
|
|
73
|
+
lines.push(`${indent}${t.warning(line)}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
lines.push("");
|
|
77
|
+
// THE SCOPE OF THE SCREEN. Every background job builds its own
|
|
78
|
+
// ApprovalService with its own allowlist, so a job's grants are neither
|
|
79
|
+
// listed here nor revoked by anything typed here. A permissions screen that
|
|
80
|
+
// implied otherwise would be understating what the session can still do.
|
|
81
|
+
lines.push(t.muted("this is the foreground session only — each background job keeps its own grants"));
|
|
82
|
+
if (report.grants && report.grants.length > 0) {
|
|
83
|
+
lines.push(t.muted("/permissions revoke <n> drops one — actions already decided keep their decision"));
|
|
84
|
+
}
|
|
85
|
+
return Number.isFinite(width)
|
|
86
|
+
? lines.map((l) => fit(l, width, t.glyph.ellipsis))
|
|
87
|
+
: lines;
|
|
88
|
+
}
|
package/dist/render/state.js
CHANGED
|
@@ -43,9 +43,28 @@ export function describePhase(phase, glyph = UNICODE_GLYPHS) {
|
|
|
43
43
|
const base = t && t.input + t.output > 0
|
|
44
44
|
? `thinking${e} ${glyph.sep} tokens ${glyph.caretUp}${formatTokens(t.input)} ${glyph.caretDown}${formatTokens(t.output)}`
|
|
45
45
|
: `thinking${e}`;
|
|
46
|
-
// The real routing tier (C.30)
|
|
47
|
-
// ` · ` joiner as the token counts.
|
|
48
|
-
|
|
46
|
+
// The real routing tier (C.30) and the effort the gateway says it applied,
|
|
47
|
+
// appended honestly when present — same ` · ` joiner as the token counts.
|
|
48
|
+
//
|
|
49
|
+
// Order matters under truncation. `fitStatusLine`'s last resort clips the
|
|
50
|
+
// TAIL, so what is appended last is shed first: effort goes before the
|
|
51
|
+
// tier, which goes before the token counts, which go before "thinking…".
|
|
52
|
+
// That is the intended priority — the tier answers "what ran", effort only
|
|
53
|
+
// refines it.
|
|
54
|
+
//
|
|
55
|
+
// Effort is labelled rather than shown bare. `kavi · none` beside a tier
|
|
56
|
+
// name reads as a second tier; `kavi · effort none` cannot. The label
|
|
57
|
+
// costs seven columns and buys the line's only defence against being
|
|
58
|
+
// misread, which is worth more here than seven columns are.
|
|
59
|
+
const parts = [base];
|
|
60
|
+
if (phase.tier)
|
|
61
|
+
parts.push(phase.tier);
|
|
62
|
+
// A present `none` is the gateway SAYING no reasoning ran — a real answer
|
|
63
|
+
// to "what actually ran", and the one this readout exists to make
|
|
64
|
+
// visible. Absent stays absent; see ServedRouting.reasoningEffort.
|
|
65
|
+
if (phase.effort)
|
|
66
|
+
parts.push(`effort ${phase.effort}`);
|
|
67
|
+
return parts.join(` ${glyph.sep} `);
|
|
49
68
|
}
|
|
50
69
|
case "calling-tool":
|
|
51
70
|
return `${phase.label}${e}`;
|
|
@@ -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
|
}
|