@cruxy/cli 0.23.0 → 0.24.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.d.ts +21 -2
- package/dist/agent/loop.js +21 -5
- package/dist/approval/index.d.ts +1 -0
- package/dist/approval/index.js +1 -0
- package/dist/approval/mutex.d.ts +45 -0
- package/dist/approval/mutex.js +57 -0
- package/dist/checkpoint/service.d.ts +9 -0
- package/dist/checkpoint/service.js +20 -0
- package/dist/cli/commands/run.js +50 -16
- package/dist/cli/onboard.js +2 -2
- package/dist/cli/repl.js +39 -0
- package/dist/cli/session-factory.d.ts +23 -1
- package/dist/cli/session-factory.js +137 -47
- package/dist/config/schema.d.ts +24 -0
- package/dist/config/schema.js +9 -0
- package/dist/errors/constructors.d.ts +23 -0
- package/dist/errors/constructors.js +38 -0
- package/dist/errors/types.d.ts +8 -0
- package/dist/errors/types.js +12 -0
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/router.d.ts +58 -0
- package/dist/hooks/router.js +136 -0
- package/dist/hooks/runner.d.ts +12 -0
- package/dist/hooks/runner.js +23 -1
- package/dist/mcp/index.d.ts +1 -0
- package/dist/mcp/index.js +1 -0
- package/dist/mcp/sibling-banner.d.ts +25 -0
- package/dist/mcp/sibling-banner.js +34 -0
- package/dist/memory/recall.d.ts +24 -0
- package/dist/memory/recall.js +54 -0
- package/dist/memory/remember-tool.d.ts +3 -0
- package/dist/memory/remember-tool.js +11 -1
- package/dist/sandbox/policy.js +14 -5
- package/dist/sandbox/service.d.ts +8 -1
- package/dist/sandbox/service.js +4 -1
- package/dist/subagent/index.d.ts +1 -0
- package/dist/subagent/index.js +1 -0
- package/dist/subagent/orchestrator.d.ts +67 -2
- package/dist/subagent/orchestrator.js +203 -18
- package/dist/subagent/registry-scope.d.ts +13 -0
- package/dist/subagent/registry-scope.js +28 -2
- package/dist/subagent/semaphore.d.ts +27 -0
- package/dist/subagent/semaphore.js +56 -0
- package/dist/subagent/spawn-tool.d.ts +57 -0
- package/dist/subagent/spawn-tool.js +104 -9
- package/dist/subagent/types.d.ts +17 -2
- package/dist/testing/run-tests-tool.js +1 -1
- package/dist/tools/file/paths.d.ts +5 -6
- package/dist/tools/file/paths.js +7 -8
- package/dist/tools/shell/exec.js +36 -4
- package/dist/tools/types.d.ts +16 -5
- package/dist/workspace/add-root.d.ts +27 -0
- package/dist/workspace/add-root.js +16 -0
- package/dist/workspace/index.d.ts +2 -1
- package/dist/workspace/index.js +2 -1
- package/dist/workspace/workspace.d.ts +9 -4
- package/dist/workspace/workspace.js +9 -4
- package/package.json +1 -1
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { runAgent } from "../agent/loop.js";
|
|
3
|
-
import { CruxyError, ErrorCode, messageOf, subagentDepthExceeded, } from "../errors/index.js";
|
|
3
|
+
import { CruxyError, ErrorCode, messageOf, subagentDepthExceeded, subagentScopeOverlap, } from "../errors/index.js";
|
|
4
|
+
import { Workspace } from "../workspace/index.js";
|
|
4
5
|
import { Budget, resolveBudget } from "./budget.js";
|
|
5
|
-
import { scopeRegistry } from "./registry-scope.js";
|
|
6
|
+
import { scopeRegistry, SUBAGENT_WRITE_TOOLS } from "./registry-scope.js";
|
|
7
|
+
import { Semaphore } from "./semaphore.js";
|
|
6
8
|
import { makeSpawnSubagentTool } from "./spawn-tool.js";
|
|
7
9
|
/** Longest task excerpt shown in render chrome — display, not record. */
|
|
8
10
|
const LABEL_MAX = 60;
|
|
@@ -21,8 +23,20 @@ const LABEL_MAX = 60;
|
|
|
21
23
|
*/
|
|
22
24
|
export class SubagentOrchestrator {
|
|
23
25
|
deps;
|
|
26
|
+
/**
|
|
27
|
+
* The ONE shared bound on parallel fan-out (C.33, JC-D). Constructed once per
|
|
28
|
+
* session and reused by every {@link spawnMany} call (nested spawns reuse this
|
|
29
|
+
* same orchestrator instance), so `subagent.maxConcurrency` caps the number of
|
|
30
|
+
* concurrently-executing subagents across the WHOLE session — not per parent.
|
|
31
|
+
*/
|
|
32
|
+
sem;
|
|
24
33
|
constructor(deps) {
|
|
25
34
|
this.deps = deps;
|
|
35
|
+
this.sem = new Semaphore(deps.config.subagent.maxConcurrency);
|
|
36
|
+
}
|
|
37
|
+
/** Live/queued fan-out slots (inspection/tests): proves the global cap holds. */
|
|
38
|
+
get concurrency() {
|
|
39
|
+
return { available: this.sem.available, waiting: this.sem.waiting };
|
|
26
40
|
}
|
|
27
41
|
/**
|
|
28
42
|
* Run one subagent to completion. `parentDepth` is the spawner's depth (the
|
|
@@ -36,7 +50,7 @@ export class SubagentOrchestrator {
|
|
|
36
50
|
* (non-interactive default-deny must reach the boundary, U.3 — a subagent is
|
|
37
51
|
* not a way to swallow it).
|
|
38
52
|
*/
|
|
39
|
-
async spawn(spec, parentDepth) {
|
|
53
|
+
async spawn(spec, parentDepth, opts = {}) {
|
|
40
54
|
const { deps } = this;
|
|
41
55
|
const { maxDepth, defaultBudget } = deps.config.subagent;
|
|
42
56
|
if (parentDepth >= maxDepth) {
|
|
@@ -45,33 +59,44 @@ export class SubagentOrchestrator {
|
|
|
45
59
|
const childDepth = parentDepth + 1;
|
|
46
60
|
// Scoped-down registry (throws on a tool the parent lacks — the spawn tool
|
|
47
61
|
// surfaces that to the model), plus a depth-bound spawn tool only while
|
|
48
|
-
// nesting is still allowed.
|
|
62
|
+
// nesting is still allowed. (Only the SEQUENTIAL spawn is re-added; parallel
|
|
63
|
+
// fan-out stays a depth-0 capability, so a child cannot nest a fan-out.)
|
|
49
64
|
const registry = scopeRegistry(deps.parentRegistry, spec.tools);
|
|
50
65
|
if (childDepth < maxDepth) {
|
|
51
66
|
registry.register(makeSpawnSubagentTool(this, childDepth));
|
|
52
67
|
}
|
|
53
68
|
const budget = new Budget(resolveBudget(defaultBudget, spec.budget));
|
|
69
|
+
// Root scoping (C.33): a `spec.root` narrows the child's cwd + confinement to
|
|
70
|
+
// that ONE root (its writes land there and nowhere else). Omitted → the full
|
|
71
|
+
// session workspace, unchanged from C.14. An unknown name fails loud here
|
|
72
|
+
// (CRUXY_E_ROOT_UNKNOWN), which the spawn tool surfaces to the model.
|
|
73
|
+
const scope = this.childScope(spec.root);
|
|
54
74
|
// Fresh gate per child (own allowlist), recording what it approves so the
|
|
55
|
-
// result can name the artifacts without ever shipping the transcript.
|
|
75
|
+
// result can name the artifacts without ever shipping the transcript. The
|
|
76
|
+
// gate itself is serialized by the shared approval mutex (wired at the
|
|
77
|
+
// session factory), so concurrent siblings never prompt or checkpoint at once.
|
|
56
78
|
const approve = deps.makeChildApproval();
|
|
57
79
|
const artifacts = new Set();
|
|
58
80
|
const ctx = {
|
|
59
|
-
cwd:
|
|
60
|
-
workspace:
|
|
81
|
+
cwd: scope.cwd,
|
|
82
|
+
workspace: scope.workspace,
|
|
61
83
|
config: deps.config,
|
|
62
84
|
logger: deps.logger,
|
|
63
85
|
requestApproval: async (action) => {
|
|
64
86
|
const decision = await approve(action);
|
|
65
87
|
if (decision.allow)
|
|
66
|
-
recordArtifacts(action, artifacts,
|
|
88
|
+
recordArtifacts(action, artifacts, scope.cwd);
|
|
67
89
|
return decision;
|
|
68
90
|
},
|
|
69
91
|
checkpointsActive: deps.checkpointsActive,
|
|
70
92
|
sandbox: deps.sandbox,
|
|
93
|
+
signal: opts.signal,
|
|
71
94
|
};
|
|
72
95
|
const label = taskLabel(spec.task);
|
|
96
|
+
const tag = opts.slot; // per-child render label for a fan-out (JC-F)
|
|
97
|
+
const noun = tag ? `subagent[${tag}]` : "subagent";
|
|
73
98
|
if (deps.renderer) {
|
|
74
|
-
deps.renderer.note(`${deps.renderer.theme.glyph.play}
|
|
99
|
+
deps.renderer.note(`${deps.renderer.theme.glyph.play} ${noun}: ${label}`);
|
|
75
100
|
}
|
|
76
101
|
deps.renderer?.setPhase({ kind: "subagent", label });
|
|
77
102
|
// The isolation seam: a brand-new history seeded with ONLY the task. The
|
|
@@ -87,7 +112,7 @@ export class SubagentOrchestrator {
|
|
|
87
112
|
config: deps.config,
|
|
88
113
|
ctx,
|
|
89
114
|
renderer: deps.renderer
|
|
90
|
-
? new SubagentRenderer(deps.renderer, label)
|
|
115
|
+
? new SubagentRenderer(deps.renderer, label, tag)
|
|
91
116
|
: undefined,
|
|
92
117
|
git: deps.git,
|
|
93
118
|
projectInstructions: deps.projectInstructions,
|
|
@@ -95,6 +120,7 @@ export class SubagentOrchestrator {
|
|
|
95
120
|
budget,
|
|
96
121
|
router: deps.router,
|
|
97
122
|
taskClass: spec.taskClass ?? "subagent",
|
|
123
|
+
signal: opts.signal,
|
|
98
124
|
});
|
|
99
125
|
}
|
|
100
126
|
catch (err) {
|
|
@@ -104,7 +130,7 @@ export class SubagentOrchestrator {
|
|
|
104
130
|
throw err;
|
|
105
131
|
}
|
|
106
132
|
if (deps.renderer) {
|
|
107
|
-
deps.renderer.note(`${deps.renderer.theme.glyph.failure}
|
|
133
|
+
deps.renderer.note(`${deps.renderer.theme.glyph.failure} ${noun} failed: ${label}`);
|
|
108
134
|
}
|
|
109
135
|
deps.renderer?.setPhase(null);
|
|
110
136
|
return {
|
|
@@ -117,13 +143,140 @@ export class SubagentOrchestrator {
|
|
|
117
143
|
};
|
|
118
144
|
}
|
|
119
145
|
deps.renderer?.setPhase(null);
|
|
120
|
-
const result = this.toResult(run, artifacts, label);
|
|
121
|
-
deps.logger.debug(
|
|
146
|
+
const result = this.toResult(run, artifacts, label, noun);
|
|
147
|
+
deps.logger.debug(`${noun} ${result.status}: ${result.iterations} turn(s), tokens in/out ` +
|
|
122
148
|
`${result.usage.input_tokens}/${result.usage.output_tokens} — ${label}`);
|
|
123
149
|
return result;
|
|
124
150
|
}
|
|
151
|
+
/**
|
|
152
|
+
* Parallel fan-out (C.33): run N children concurrently under the shared
|
|
153
|
+
* concurrency semaphore and fold their outcomes into a result array whose
|
|
154
|
+
* order MATCHES `specs` (position i is spec i's result — never completion
|
|
155
|
+
* order). A DEPTH-0 capability only (the plural tool is never granted to a
|
|
156
|
+
* child), so no permit holder ever nests a second fan-out — the semaphore
|
|
157
|
+
* stays deadlock-free.
|
|
158
|
+
*
|
|
159
|
+
* Safety before dispatch: overlapping write scope is REFUSED
|
|
160
|
+
* (`CRUXY_E_SUBAGENT_SCOPE_OVERLAP`) so two writers can never race on one root.
|
|
161
|
+
*
|
|
162
|
+
* Cancellation: children share one {@link AbortController}. A child returning a
|
|
163
|
+
* `failed`/`budget-exceeded` result is a normal PARTIAL outcome — siblings run
|
|
164
|
+
* on. But a *fatal* throw from any child (non-interactive default-deny) or an
|
|
165
|
+
* abort on `opts.signal` (Ctrl-C) aborts the controller: every sibling stops at
|
|
166
|
+
* its next turn boundary and its in-flight shell child is kill-tree'd, so the
|
|
167
|
+
* fan-out leaves no orphan. All children are awaited to settle before a fatal
|
|
168
|
+
* throw propagates — never a detached, still-running sibling.
|
|
169
|
+
*/
|
|
170
|
+
async spawnMany(specs, parentDepth, opts = {}) {
|
|
171
|
+
const { maxDepth } = this.deps.config.subagent;
|
|
172
|
+
if (parentDepth >= maxDepth) {
|
|
173
|
+
throw subagentDepthExceeded(parentDepth, maxDepth);
|
|
174
|
+
}
|
|
175
|
+
if (specs.length === 0)
|
|
176
|
+
return [];
|
|
177
|
+
// Refuse overlapping write scope BEFORE any child is dispatched.
|
|
178
|
+
this.assertDisjointWriteScopes(specs);
|
|
179
|
+
const controller = new AbortController();
|
|
180
|
+
const onExternalAbort = () => controller.abort();
|
|
181
|
+
if (opts.signal) {
|
|
182
|
+
if (opts.signal.aborted)
|
|
183
|
+
controller.abort();
|
|
184
|
+
else
|
|
185
|
+
opts.signal.addEventListener("abort", onExternalAbort, { once: true });
|
|
186
|
+
}
|
|
187
|
+
const results = new Array(specs.length);
|
|
188
|
+
const total = specs.length;
|
|
189
|
+
try {
|
|
190
|
+
const settled = await Promise.allSettled(specs.map((spec, i) => this.sem.run(async () => {
|
|
191
|
+
// Already cancelled (a fatal sibling or Ctrl-C fired first): record an
|
|
192
|
+
// honest cancelled result instead of starting a doomed run.
|
|
193
|
+
if (controller.signal.aborted) {
|
|
194
|
+
results[i] = cancelledResult();
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
results[i] = await this.spawn(spec, parentDepth, {
|
|
199
|
+
signal: controller.signal,
|
|
200
|
+
slot: `${i + 1}/${total}`,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
// A fatal throw (non-interactive default-deny) cancels the whole
|
|
205
|
+
// fan-out — no sibling is left running — then propagates.
|
|
206
|
+
controller.abort();
|
|
207
|
+
throw err;
|
|
208
|
+
}
|
|
209
|
+
})));
|
|
210
|
+
// Any child that was aborted mid-flight (returned stop:"aborted") is folded
|
|
211
|
+
// as cancelled by toResult; a fatal throw surfaces here after all settled.
|
|
212
|
+
const fatal = settled.find((s) => s.status === "rejected");
|
|
213
|
+
if (fatal && fatal.status === "rejected")
|
|
214
|
+
throw fatal.reason;
|
|
215
|
+
// Backfill any slot a cancelled-before-dispatch child left (defensive: the
|
|
216
|
+
// sem callback always assigns, but never ship a hole as success).
|
|
217
|
+
for (let i = 0; i < results.length; i++) {
|
|
218
|
+
if (results[i] === undefined)
|
|
219
|
+
results[i] = cancelledResult();
|
|
220
|
+
}
|
|
221
|
+
return results;
|
|
222
|
+
}
|
|
223
|
+
finally {
|
|
224
|
+
opts.signal?.removeEventListener("abort", onExternalAbort);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Resolve a child's scope from an optional root name. With a name: a
|
|
229
|
+
* single-root workspace over that root (writes confined to it) + that root's
|
|
230
|
+
* cwd. Without: the full session workspace + primary cwd (C.14 behaviour).
|
|
231
|
+
*/
|
|
232
|
+
childScope(rootName) {
|
|
233
|
+
if (rootName === undefined) {
|
|
234
|
+
return { cwd: this.deps.cwd, workspace: this.deps.workspace };
|
|
235
|
+
}
|
|
236
|
+
const root = this.deps.workspace.rootByName(rootName); // fail-loud on unknown
|
|
237
|
+
return {
|
|
238
|
+
cwd: root.absPath,
|
|
239
|
+
workspace: new Workspace([
|
|
240
|
+
{ name: root.name, absPath: root.absPath, primary: true },
|
|
241
|
+
]),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Refuse a fan-out where two WRITING children (any mutating tool granted)
|
|
246
|
+
* target the same root — the disjoint-scope guarantee (C.33). A writer with no
|
|
247
|
+
* declared root defaults to the session PRIMARY, so in a single-root session at
|
|
248
|
+
* most one child may write per batch (the rest must be read-only). Read-only
|
|
249
|
+
* children never conflict.
|
|
250
|
+
*
|
|
251
|
+
* Collects EVERY colliding root (not just the first) so the refusal names all
|
|
252
|
+
* conflicting task pairs at once — the model can fix them in one correction.
|
|
253
|
+
* The check is on DECLARED scope (tools + root), an honest over-approximation
|
|
254
|
+
* the error message is explicit about.
|
|
255
|
+
*/
|
|
256
|
+
assertDisjointWriteScopes(specs) {
|
|
257
|
+
const primaryName = this.deps.workspace.primary().name;
|
|
258
|
+
const byRoot = new Map(); // root → writing-child labels
|
|
259
|
+
for (const spec of specs) {
|
|
260
|
+
if (!isWriter(spec))
|
|
261
|
+
continue;
|
|
262
|
+
// Validate the named root exists (fail-loud, same as spawn) before claiming.
|
|
263
|
+
const rootName = spec.root === undefined
|
|
264
|
+
? primaryName
|
|
265
|
+
: this.deps.workspace.rootByName(spec.root).name;
|
|
266
|
+
const claimants = byRoot.get(rootName);
|
|
267
|
+
if (claimants)
|
|
268
|
+
claimants.push(taskLabel(spec.task));
|
|
269
|
+
else
|
|
270
|
+
byRoot.set(rootName, [taskLabel(spec.task)]);
|
|
271
|
+
}
|
|
272
|
+
const conflicts = [...byRoot.entries()]
|
|
273
|
+
.filter(([, tasks]) => tasks.length > 1)
|
|
274
|
+
.map(([root, tasks]) => ({ root, tasks }));
|
|
275
|
+
if (conflicts.length > 0)
|
|
276
|
+
throw subagentScopeOverlap(conflicts);
|
|
277
|
+
}
|
|
125
278
|
/** Map the child's AgentResult to the structured, transcript-free shape. */
|
|
126
|
-
toResult(run, artifacts, label) {
|
|
279
|
+
toResult(run, artifacts, label, noun) {
|
|
127
280
|
const summary = lastAssistantText(run.messages);
|
|
128
281
|
const base = {
|
|
129
282
|
summary,
|
|
@@ -134,9 +287,21 @@ export class SubagentOrchestrator {
|
|
|
134
287
|
if (run.stop === "completed") {
|
|
135
288
|
const r = this.deps.renderer;
|
|
136
289
|
if (r)
|
|
137
|
-
r.note(`${r.theme.glyph.success}
|
|
290
|
+
r.note(`${r.theme.glyph.success} ${noun} done: ${label}`);
|
|
138
291
|
return { status: "done", ...base };
|
|
139
292
|
}
|
|
293
|
+
// Cancellation (C.33): a fatal sibling failure or Ctrl-C stopped this child
|
|
294
|
+
// at a turn boundary. An honest partial result — never a fabricated success.
|
|
295
|
+
if (run.stop === "aborted") {
|
|
296
|
+
const r = this.deps.renderer;
|
|
297
|
+
if (r)
|
|
298
|
+
r.note(`${r.theme.glyph.failure} ${noun} cancelled: ${label}`);
|
|
299
|
+
return {
|
|
300
|
+
status: "cancelled",
|
|
301
|
+
...base,
|
|
302
|
+
error: `${ErrorCode.SubagentCancelled}: cancelled before completion`,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
140
305
|
// Both cap paths are the same outcome for the parent: a truncated, partial
|
|
141
306
|
// result with the reason — informational, never fatal (the parent decides
|
|
142
307
|
// what to do with it).
|
|
@@ -145,7 +310,7 @@ export class SubagentOrchestrator {
|
|
|
145
310
|
: `agent.maxIterations ceiling reached (${this.deps.config.agent.maxIterations})`;
|
|
146
311
|
const r = this.deps.renderer;
|
|
147
312
|
if (r)
|
|
148
|
-
r.note(`${r.theme.glyph.failure}
|
|
313
|
+
r.note(`${r.theme.glyph.failure} ${noun} stopped (budget): ${label}`);
|
|
149
314
|
return {
|
|
150
315
|
status: "budget-exceeded",
|
|
151
316
|
...base,
|
|
@@ -158,6 +323,22 @@ function taskLabel(task) {
|
|
|
158
323
|
const flat = task.replace(/\s+/g, " ").trim();
|
|
159
324
|
return flat.length > LABEL_MAX ? flat.slice(0, LABEL_MAX - 1) + "…" : flat;
|
|
160
325
|
}
|
|
326
|
+
/** A child that holds any mutating tool — the disjoint-scope check's unit. A
|
|
327
|
+
* spec with no `tools` gets the default READ-ONLY set, so it is never a writer. */
|
|
328
|
+
function isWriter(spec) {
|
|
329
|
+
return (spec.tools ?? []).some((t) => SUBAGENT_WRITE_TOOLS.has(t));
|
|
330
|
+
}
|
|
331
|
+
/** The honest result for a child cancelled before it could produce anything —
|
|
332
|
+
* used when a fatal sibling / Ctrl-C fired before this slot even dispatched. */
|
|
333
|
+
function cancelledResult() {
|
|
334
|
+
return {
|
|
335
|
+
status: "cancelled",
|
|
336
|
+
summary: "",
|
|
337
|
+
error: `${ErrorCode.SubagentCancelled}: cancelled before dispatch`,
|
|
338
|
+
iterations: 0,
|
|
339
|
+
usage: { input_tokens: 0, output_tokens: 0 },
|
|
340
|
+
};
|
|
341
|
+
}
|
|
161
342
|
/** `artifacts` only when non-empty — absent beats `[]` in the parent's context. */
|
|
162
343
|
function artifactsField(artifacts) {
|
|
163
344
|
return artifacts.size > 0 ? { artifacts: [...artifacts].sort() } : {};
|
|
@@ -212,12 +393,16 @@ class SubagentRenderer {
|
|
|
212
393
|
inner;
|
|
213
394
|
label;
|
|
214
395
|
prefix;
|
|
215
|
-
constructor(inner, label) {
|
|
396
|
+
constructor(inner, label, tag) {
|
|
216
397
|
this.inner = inner;
|
|
217
398
|
this.label = label;
|
|
218
399
|
this.caps = inner.caps;
|
|
219
400
|
this.theme = inner.theme;
|
|
220
|
-
|
|
401
|
+
// A fan-out child carries its slot in the prefix (`subagent[2/3] · …`) so
|
|
402
|
+
// interleaved trail notes stay attributable per-subagent (JC-F); a lone
|
|
403
|
+
// sequential spawn keeps the byte-identical C.14 `subagent · …` prefix.
|
|
404
|
+
const noun = tag ? `subagent[${tag}]` : "subagent";
|
|
405
|
+
this.prefix = `${noun} ${inner.theme.glyph.sep} `;
|
|
221
406
|
}
|
|
222
407
|
/** Turn framing belongs to the parent's turn — the child's is dropped. */
|
|
223
408
|
beginTurn() { }
|
|
@@ -8,6 +8,19 @@ import { ToolRegistry } from "../tools/index.js";
|
|
|
8
8
|
/** The spawn tool's registered name (excluded from every scoped child set —
|
|
9
9
|
* the orchestrator re-adds a depth-bound instance only while depth allows). */
|
|
10
10
|
export declare const SPAWN_SUBAGENT_TOOL_NAME = "spawn_subagent";
|
|
11
|
+
/** The parallel fan-out tool's name (C.33). Stripped from every child scope:
|
|
12
|
+
* parallel fan-out is a depth-0 capability only, so a child can never obtain it
|
|
13
|
+
* (which is also what keeps the concurrency semaphore deadlock-free — no permit
|
|
14
|
+
* holder ever nests a second fan-out). A child may still spawn ONE sequential
|
|
15
|
+
* subagent via {@link SPAWN_SUBAGENT_TOOL_NAME} when depth allows. */
|
|
16
|
+
export declare const SPAWN_SUBAGENTS_TOOL_NAME = "spawn_subagents";
|
|
17
|
+
/**
|
|
18
|
+
* Mutating tools (C.33): a child holding ANY of these is a "writer" for the
|
|
19
|
+
* disjoint-scope check. Two writers in one parallel batch must target distinct
|
|
20
|
+
* roots, or the batch is refused pre-dispatch. Kept in sync with the gated,
|
|
21
|
+
* side-effecting tool set (file writes, shell/test, VCS).
|
|
22
|
+
*/
|
|
23
|
+
export declare const SUBAGENT_WRITE_TOOLS: ReadonlySet<string>;
|
|
11
24
|
/**
|
|
12
25
|
* The default child toolset: read-only investigation plus skills. Mirrors the
|
|
13
26
|
* C.31 propose-phase set — no writes, no shell, no VCS unless the parent
|
|
@@ -8,6 +8,28 @@ import { ToolRegistry } from "../tools/index.js";
|
|
|
8
8
|
/** The spawn tool's registered name (excluded from every scoped child set —
|
|
9
9
|
* the orchestrator re-adds a depth-bound instance only while depth allows). */
|
|
10
10
|
export const SPAWN_SUBAGENT_TOOL_NAME = "spawn_subagent";
|
|
11
|
+
/** The parallel fan-out tool's name (C.33). Stripped from every child scope:
|
|
12
|
+
* parallel fan-out is a depth-0 capability only, so a child can never obtain it
|
|
13
|
+
* (which is also what keeps the concurrency semaphore deadlock-free — no permit
|
|
14
|
+
* holder ever nests a second fan-out). A child may still spawn ONE sequential
|
|
15
|
+
* subagent via {@link SPAWN_SUBAGENT_TOOL_NAME} when depth allows. */
|
|
16
|
+
export const SPAWN_SUBAGENTS_TOOL_NAME = "spawn_subagents";
|
|
17
|
+
/**
|
|
18
|
+
* Mutating tools (C.33): a child holding ANY of these is a "writer" for the
|
|
19
|
+
* disjoint-scope check. Two writers in one parallel batch must target distinct
|
|
20
|
+
* roots, or the batch is refused pre-dispatch. Kept in sync with the gated,
|
|
21
|
+
* side-effecting tool set (file writes, shell/test, VCS).
|
|
22
|
+
*/
|
|
23
|
+
export const SUBAGENT_WRITE_TOOLS = new Set([
|
|
24
|
+
"write_file",
|
|
25
|
+
"edit_file",
|
|
26
|
+
"apply_patch",
|
|
27
|
+
"run_command",
|
|
28
|
+
"run_tests",
|
|
29
|
+
"git_commit",
|
|
30
|
+
"git_branch",
|
|
31
|
+
"open_pr",
|
|
32
|
+
]);
|
|
11
33
|
/**
|
|
12
34
|
* The default child toolset: read-only investigation plus skills. Mirrors the
|
|
13
35
|
* C.31 propose-phase set — no writes, no shell, no VCS unless the parent
|
|
@@ -45,14 +67,18 @@ export function scopeRegistry(parent, requested) {
|
|
|
45
67
|
return child;
|
|
46
68
|
}
|
|
47
69
|
for (const name of new Set(requested)) {
|
|
48
|
-
|
|
70
|
+
// Neither spawn tool is grantable to a child: nesting (sequential) is the
|
|
71
|
+
// orchestrator's depth-capped decision, and parallel fan-out is depth-0 only.
|
|
72
|
+
if (name === SPAWN_SUBAGENT_TOOL_NAME ||
|
|
73
|
+
name === SPAWN_SUBAGENTS_TOOL_NAME) {
|
|
49
74
|
continue;
|
|
75
|
+
}
|
|
50
76
|
const tool = parent.get(name);
|
|
51
77
|
if (!tool) {
|
|
52
78
|
const available = parent
|
|
53
79
|
.list()
|
|
54
80
|
.map((t) => t.name)
|
|
55
|
-
.filter((n) => n !== SPAWN_SUBAGENT_TOOL_NAME)
|
|
81
|
+
.filter((n) => n !== SPAWN_SUBAGENT_TOOL_NAME && n !== SPAWN_SUBAGENTS_TOOL_NAME)
|
|
56
82
|
.join(", ");
|
|
57
83
|
throw new Error(`tool "${name}" is not available to grant a subagent (a subagent's tools ` +
|
|
58
84
|
`must be a subset of yours). Available: ${available}`);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A counting semaphore (C.33): bounds how many subagent runs execute at once.
|
|
3
|
+
* FIFO — waiters are served in arrival order, so a fan-out's results stay
|
|
4
|
+
* dispatch-order-fair — and the permit is handed directly from a releaser to
|
|
5
|
+
* the next waiter, so the live count never transiently exceeds the cap.
|
|
6
|
+
*
|
|
7
|
+
* Used as the ONE shared bound on parallel fan-out. Parallel dispatch happens at
|
|
8
|
+
* depth 0 only (the `spawn_subagents` tool is never granted to a child), and a
|
|
9
|
+
* permit is held for a child's whole lifetime — including any *sequential*
|
|
10
|
+
* nested spawn beneath it, which is deliberately un-permitted. Because no permit
|
|
11
|
+
* holder ever blocks trying to acquire a second permit, the semaphore cannot be
|
|
12
|
+
* part of a wait cycle: it is deadlock-free by construction (see the C.33 design
|
|
13
|
+
* doc's deadlock argument).
|
|
14
|
+
*/
|
|
15
|
+
export declare class Semaphore {
|
|
16
|
+
private permits;
|
|
17
|
+
private readonly queue;
|
|
18
|
+
constructor(permits: number);
|
|
19
|
+
/** Run `fn` while holding one permit; the permit is released even if it throws. */
|
|
20
|
+
run<T>(fn: () => Promise<T>): Promise<T>;
|
|
21
|
+
/** Permits currently available (inspection/tests). */
|
|
22
|
+
get available(): number;
|
|
23
|
+
/** Callers currently blocked waiting for a permit (inspection/tests). */
|
|
24
|
+
get waiting(): number;
|
|
25
|
+
private acquire;
|
|
26
|
+
private release;
|
|
27
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A counting semaphore (C.33): bounds how many subagent runs execute at once.
|
|
3
|
+
* FIFO — waiters are served in arrival order, so a fan-out's results stay
|
|
4
|
+
* dispatch-order-fair — and the permit is handed directly from a releaser to
|
|
5
|
+
* the next waiter, so the live count never transiently exceeds the cap.
|
|
6
|
+
*
|
|
7
|
+
* Used as the ONE shared bound on parallel fan-out. Parallel dispatch happens at
|
|
8
|
+
* depth 0 only (the `spawn_subagents` tool is never granted to a child), and a
|
|
9
|
+
* permit is held for a child's whole lifetime — including any *sequential*
|
|
10
|
+
* nested spawn beneath it, which is deliberately un-permitted. Because no permit
|
|
11
|
+
* holder ever blocks trying to acquire a second permit, the semaphore cannot be
|
|
12
|
+
* part of a wait cycle: it is deadlock-free by construction (see the C.33 design
|
|
13
|
+
* doc's deadlock argument).
|
|
14
|
+
*/
|
|
15
|
+
export class Semaphore {
|
|
16
|
+
permits;
|
|
17
|
+
queue = [];
|
|
18
|
+
constructor(permits) {
|
|
19
|
+
// A non-positive cap would wedge every run; clamp to at least 1.
|
|
20
|
+
this.permits = Math.max(1, Math.floor(permits));
|
|
21
|
+
}
|
|
22
|
+
/** Run `fn` while holding one permit; the permit is released even if it throws. */
|
|
23
|
+
async run(fn) {
|
|
24
|
+
await this.acquire();
|
|
25
|
+
try {
|
|
26
|
+
return await fn();
|
|
27
|
+
}
|
|
28
|
+
finally {
|
|
29
|
+
this.release();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** Permits currently available (inspection/tests). */
|
|
33
|
+
get available() {
|
|
34
|
+
return this.permits;
|
|
35
|
+
}
|
|
36
|
+
/** Callers currently blocked waiting for a permit (inspection/tests). */
|
|
37
|
+
get waiting() {
|
|
38
|
+
return this.queue.length;
|
|
39
|
+
}
|
|
40
|
+
acquire() {
|
|
41
|
+
if (this.permits > 0) {
|
|
42
|
+
this.permits--;
|
|
43
|
+
return Promise.resolve();
|
|
44
|
+
}
|
|
45
|
+
return new Promise((resolve) => this.queue.push(resolve));
|
|
46
|
+
}
|
|
47
|
+
release() {
|
|
48
|
+
const next = this.queue.shift();
|
|
49
|
+
// Hand the permit straight to the next waiter (never bump the count above
|
|
50
|
+
// the cap); only when nobody waits does the count grow back.
|
|
51
|
+
if (next)
|
|
52
|
+
next();
|
|
53
|
+
else
|
|
54
|
+
this.permits++;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -26,4 +26,61 @@ declare const parameters: z.ZodObject<{
|
|
|
26
26
|
}>;
|
|
27
27
|
/** Build a `spawn_subagent` tool bound to `orchestrator` at `depth`. */
|
|
28
28
|
export declare function makeSpawnSubagentTool(orchestrator: SubagentOrchestrator, depth: number): Tool<typeof parameters>;
|
|
29
|
+
declare const batchParameters: z.ZodObject<{
|
|
30
|
+
tasks: z.ZodArray<z.ZodObject<{
|
|
31
|
+
task: z.ZodString;
|
|
32
|
+
tools: z.ZodOptional<z.ZodArray<z.ZodString, "atleastone">>;
|
|
33
|
+
root: z.ZodOptional<z.ZodString>;
|
|
34
|
+
maxIterations: z.ZodOptional<z.ZodNumber>;
|
|
35
|
+
maxTokens: z.ZodOptional<z.ZodNumber>;
|
|
36
|
+
}, "strip", z.ZodTypeAny, {
|
|
37
|
+
task: string;
|
|
38
|
+
root?: string | undefined;
|
|
39
|
+
maxTokens?: number | undefined;
|
|
40
|
+
maxIterations?: number | undefined;
|
|
41
|
+
tools?: [string, ...string[]] | undefined;
|
|
42
|
+
}, {
|
|
43
|
+
task: string;
|
|
44
|
+
root?: string | undefined;
|
|
45
|
+
maxTokens?: number | undefined;
|
|
46
|
+
maxIterations?: number | undefined;
|
|
47
|
+
tools?: [string, ...string[]] | undefined;
|
|
48
|
+
}>, "atleastone">;
|
|
49
|
+
}, "strip", z.ZodTypeAny, {
|
|
50
|
+
tasks: [{
|
|
51
|
+
task: string;
|
|
52
|
+
root?: string | undefined;
|
|
53
|
+
maxTokens?: number | undefined;
|
|
54
|
+
maxIterations?: number | undefined;
|
|
55
|
+
tools?: [string, ...string[]] | undefined;
|
|
56
|
+
}, ...{
|
|
57
|
+
task: string;
|
|
58
|
+
root?: string | undefined;
|
|
59
|
+
maxTokens?: number | undefined;
|
|
60
|
+
maxIterations?: number | undefined;
|
|
61
|
+
tools?: [string, ...string[]] | undefined;
|
|
62
|
+
}[]];
|
|
63
|
+
}, {
|
|
64
|
+
tasks: [{
|
|
65
|
+
task: string;
|
|
66
|
+
root?: string | undefined;
|
|
67
|
+
maxTokens?: number | undefined;
|
|
68
|
+
maxIterations?: number | undefined;
|
|
69
|
+
tools?: [string, ...string[]] | undefined;
|
|
70
|
+
}, ...{
|
|
71
|
+
task: string;
|
|
72
|
+
root?: string | undefined;
|
|
73
|
+
maxTokens?: number | undefined;
|
|
74
|
+
maxIterations?: number | undefined;
|
|
75
|
+
tools?: [string, ...string[]] | undefined;
|
|
76
|
+
}[]];
|
|
77
|
+
}>;
|
|
78
|
+
/**
|
|
79
|
+
* Build the `spawn_subagents` tool (C.33) — the PARALLEL fan-out seam, bound to
|
|
80
|
+
* `depth`. One tool call dispatches N independent, internally-sequential children
|
|
81
|
+
* concurrently (JC-A) under the shared concurrency semaphore, and returns their
|
|
82
|
+
* results IN REQUEST ORDER. A DEPTH-0 capability: it is never granted to a child,
|
|
83
|
+
* so fan-out never nests (which keeps the semaphore deadlock-free).
|
|
84
|
+
*/
|
|
85
|
+
export declare function makeSpawnSubagentsTool(orchestrator: SubagentOrchestrator, depth: number): Tool<typeof batchParameters>;
|
|
29
86
|
export {};
|