@cruxy/cli 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -13
- package/dist/agent/loop.d.ts +35 -6
- package/dist/agent/loop.js +84 -10
- package/dist/agent/prompts.d.ts +2 -0
- package/dist/agent/prompts.js +8 -0
- package/dist/agent/session.d.ts +6 -4
- package/dist/agent/session.js +6 -5
- package/dist/approval/classify.js +26 -0
- package/dist/approval/prompt.d.ts +9 -0
- package/dist/approval/prompt.js +2 -77
- package/dist/checkpoint/capture.d.ts +17 -0
- package/dist/checkpoint/capture.js +73 -0
- package/dist/checkpoint/git-store.d.ts +61 -0
- package/dist/checkpoint/git-store.js +171 -0
- package/dist/checkpoint/index.d.ts +6 -0
- package/dist/checkpoint/index.js +6 -0
- package/dist/checkpoint/restore.d.ts +23 -0
- package/dist/checkpoint/restore.js +195 -0
- package/dist/checkpoint/service.d.ts +80 -0
- package/dist/checkpoint/service.js +276 -0
- package/dist/checkpoint/shadow-store.d.ts +23 -0
- package/dist/checkpoint/shadow-store.js +93 -0
- package/dist/checkpoint/types.d.ts +117 -0
- package/dist/checkpoint/types.js +18 -0
- package/dist/cli/commands/checkpoint.d.ts +7 -0
- package/dist/cli/commands/checkpoint.js +31 -0
- package/dist/cli/commands/rollback.d.ts +10 -0
- package/dist/cli/commands/rollback.js +51 -0
- package/dist/cli/commands/run.js +24 -10
- package/dist/cli/onboard.js +9 -4
- package/dist/cli/program.js +4 -0
- package/dist/cli/repl.d.ts +10 -4
- package/dist/cli/repl.js +26 -12
- package/dist/cli/session-factory.d.ts +15 -1
- package/dist/cli/session-factory.js +104 -18
- package/dist/config/schema.d.ts +133 -0
- package/dist/config/schema.js +40 -0
- package/dist/errors/constructors.d.ts +25 -0
- package/dist/errors/constructors.js +86 -0
- package/dist/errors/types.d.ts +7 -0
- package/dist/errors/types.js +16 -0
- package/dist/indexing/walker.d.ts +11 -0
- package/dist/indexing/walker.js +11 -6
- package/dist/plan/execute.d.ts +8 -0
- package/dist/plan/execute.js +36 -22
- package/dist/plan/service.d.ts +2 -1
- package/dist/plan/service.js +7 -3
- package/dist/plan/submit-plan.d.ts +4 -4
- package/dist/render/capabilities.d.ts +12 -0
- package/dist/render/capabilities.js +27 -0
- package/dist/render/diff.d.ts +19 -0
- package/dist/render/diff.js +107 -0
- package/dist/render/highlight.d.ts +47 -0
- package/dist/render/highlight.js +265 -0
- package/dist/render/index.d.ts +15 -0
- package/dist/render/index.js +21 -0
- package/dist/render/plain-renderer.d.ts +38 -0
- package/dist/render/plain-renderer.js +87 -0
- package/dist/render/state.d.ts +31 -0
- package/dist/render/state.js +83 -0
- package/dist/render/tty-renderer.d.ts +83 -0
- package/dist/render/tty-renderer.js +276 -0
- package/dist/render/types.d.ts +160 -0
- package/dist/render/types.js +1 -0
- package/dist/subagent/budget.d.ts +34 -0
- package/dist/subagent/budget.js +57 -0
- package/dist/subagent/index.d.ts +5 -0
- package/dist/subagent/index.js +5 -0
- package/dist/subagent/orchestrator.d.ts +67 -0
- package/dist/subagent/orchestrator.js +241 -0
- package/dist/subagent/registry-scope.d.ts +28 -0
- package/dist/subagent/registry-scope.js +63 -0
- package/dist/subagent/spawn-tool.d.ts +29 -0
- package/dist/subagent/spawn-tool.js +94 -0
- package/dist/subagent/types.d.ts +55 -0
- package/dist/subagent/types.js +1 -0
- package/dist/tools/types.d.ts +20 -2
- package/package.json +1 -1
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { runAgent } from "../agent/loop.js";
|
|
3
|
+
import { CruxyError, ErrorCode, messageOf, subagentDepthExceeded, } from "../errors/index.js";
|
|
4
|
+
import { Budget, resolveBudget } from "./budget.js";
|
|
5
|
+
import { scopeRegistry } from "./registry-scope.js";
|
|
6
|
+
import { makeSpawnSubagentTool } from "./spawn-tool.js";
|
|
7
|
+
/** Longest task excerpt shown in render chrome — display, not record. */
|
|
8
|
+
const LABEL_MAX = 60;
|
|
9
|
+
/**
|
|
10
|
+
* Spawns subagents (C.14): the existing agent loop re-driven over isolated
|
|
11
|
+
* state. Each spawn builds a fresh history seeded with only the task, a
|
|
12
|
+
* scoped-down registry, a fresh approval gate, and a hard budget, runs
|
|
13
|
+
* `runAgent` to completion (or cap), and folds the outcome into a compact
|
|
14
|
+
* {@link SubagentResult}. The child transcript is dropped here — the parent
|
|
15
|
+
* never sees it.
|
|
16
|
+
*
|
|
17
|
+
* Spawns are sequential by construction: the loop awaits each tool call, so
|
|
18
|
+
* two spawn requests in one turn run one after the other in deterministic
|
|
19
|
+
* order. Parallel fan-out is C.33's job — `spawn` is a self-contained async
|
|
20
|
+
* unit with no cross-spawn state, so it slots in without reshaping this class.
|
|
21
|
+
*/
|
|
22
|
+
export class SubagentOrchestrator {
|
|
23
|
+
deps;
|
|
24
|
+
constructor(deps) {
|
|
25
|
+
this.deps = deps;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Run one subagent to completion. `parentDepth` is the spawner's depth (the
|
|
29
|
+
* main agent is 0); spawning past `subagent.maxDepth` throws
|
|
30
|
+
* `CRUXY_E_SUBAGENT_DEPTH_EXCEEDED` — the spawn tool is also structurally
|
|
31
|
+
* withheld at the cap, so this throw is the fail-loud backstop.
|
|
32
|
+
*
|
|
33
|
+
* Never rejects on a *child* failure — provider or tool crashes come back as
|
|
34
|
+
* `status: "failed"` for the parent to reason over. The two exceptions that
|
|
35
|
+
* do propagate: the depth cap (above) and `CRUXY_E_APPROVAL_REQUIRED`
|
|
36
|
+
* (non-interactive default-deny must reach the boundary, U.3 — a subagent is
|
|
37
|
+
* not a way to swallow it).
|
|
38
|
+
*/
|
|
39
|
+
async spawn(spec, parentDepth) {
|
|
40
|
+
const { deps } = this;
|
|
41
|
+
const { maxDepth, defaultBudget } = deps.config.subagent;
|
|
42
|
+
if (parentDepth >= maxDepth) {
|
|
43
|
+
throw subagentDepthExceeded(parentDepth, maxDepth);
|
|
44
|
+
}
|
|
45
|
+
const childDepth = parentDepth + 1;
|
|
46
|
+
// Scoped-down registry (throws on a tool the parent lacks — the spawn tool
|
|
47
|
+
// surfaces that to the model), plus a depth-bound spawn tool only while
|
|
48
|
+
// nesting is still allowed.
|
|
49
|
+
const registry = scopeRegistry(deps.parentRegistry, spec.tools);
|
|
50
|
+
if (childDepth < maxDepth) {
|
|
51
|
+
registry.register(makeSpawnSubagentTool(this, childDepth));
|
|
52
|
+
}
|
|
53
|
+
const budget = new Budget(resolveBudget(defaultBudget, spec.budget));
|
|
54
|
+
// Fresh gate per child (own allowlist), recording what it approves so the
|
|
55
|
+
// result can name the artifacts without ever shipping the transcript.
|
|
56
|
+
const approve = deps.makeChildApproval();
|
|
57
|
+
const artifacts = new Set();
|
|
58
|
+
const ctx = {
|
|
59
|
+
cwd: deps.cwd,
|
|
60
|
+
config: deps.config,
|
|
61
|
+
logger: deps.logger,
|
|
62
|
+
requestApproval: async (action) => {
|
|
63
|
+
const decision = await approve(action);
|
|
64
|
+
if (decision.allow)
|
|
65
|
+
recordArtifacts(action, artifacts, deps.cwd);
|
|
66
|
+
return decision;
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
const label = taskLabel(spec.task);
|
|
70
|
+
deps.renderer?.note(`⏵ subagent: ${label}`);
|
|
71
|
+
deps.renderer?.setPhase({ kind: "subagent", label });
|
|
72
|
+
// The isolation seam: a brand-new history seeded with ONLY the task. The
|
|
73
|
+
// parent's messages are never in scope here, and this array dies with the
|
|
74
|
+
// spawn — only the structured result below leaves this function.
|
|
75
|
+
const messages = [{ role: "user", content: spec.task }];
|
|
76
|
+
let run;
|
|
77
|
+
try {
|
|
78
|
+
run = await runAgent({
|
|
79
|
+
messages,
|
|
80
|
+
provider: deps.provider,
|
|
81
|
+
registry,
|
|
82
|
+
config: deps.config,
|
|
83
|
+
ctx,
|
|
84
|
+
renderer: deps.renderer
|
|
85
|
+
? new SubagentRenderer(deps.renderer, label)
|
|
86
|
+
: undefined,
|
|
87
|
+
git: deps.git,
|
|
88
|
+
projectInstructions: deps.projectInstructions,
|
|
89
|
+
subagent: true,
|
|
90
|
+
budget,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
// Non-interactive default-deny is the boundary's to report, not ours.
|
|
95
|
+
if (CruxyError.is(err) && err.code === ErrorCode.ApprovalRequired) {
|
|
96
|
+
deps.renderer?.setPhase(null);
|
|
97
|
+
throw err;
|
|
98
|
+
}
|
|
99
|
+
deps.renderer?.note(`✗ subagent failed: ${label}`);
|
|
100
|
+
deps.renderer?.setPhase(null);
|
|
101
|
+
return {
|
|
102
|
+
status: "failed",
|
|
103
|
+
summary: "",
|
|
104
|
+
...artifactsField(artifacts),
|
|
105
|
+
error: `${ErrorCode.SubagentFailed}: ${messageOf(err) ?? "unknown error"}`,
|
|
106
|
+
iterations: 0,
|
|
107
|
+
usage: { input_tokens: 0, output_tokens: 0 },
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
deps.renderer?.setPhase(null);
|
|
111
|
+
const result = this.toResult(run, artifacts, label);
|
|
112
|
+
deps.logger.debug(`subagent ${result.status}: ${result.iterations} turn(s), tokens in/out ` +
|
|
113
|
+
`${result.usage.input_tokens}/${result.usage.output_tokens} — ${label}`);
|
|
114
|
+
return result;
|
|
115
|
+
}
|
|
116
|
+
/** Map the child's AgentResult to the structured, transcript-free shape. */
|
|
117
|
+
toResult(run, artifacts, label) {
|
|
118
|
+
const summary = lastAssistantText(run.messages);
|
|
119
|
+
const base = {
|
|
120
|
+
summary,
|
|
121
|
+
...artifactsField(artifacts),
|
|
122
|
+
iterations: run.iterations,
|
|
123
|
+
usage: run.usage,
|
|
124
|
+
};
|
|
125
|
+
if (run.stop === "completed") {
|
|
126
|
+
this.deps.renderer?.note(`✓ subagent done: ${label}`);
|
|
127
|
+
return { status: "done", ...base };
|
|
128
|
+
}
|
|
129
|
+
// Both cap paths are the same outcome for the parent: a truncated, partial
|
|
130
|
+
// result with the reason — informational, never fatal (the parent decides
|
|
131
|
+
// what to do with it).
|
|
132
|
+
const reason = run.stop === "budget"
|
|
133
|
+
? (run.stopReason ?? "budget cap reached")
|
|
134
|
+
: `agent.maxIterations ceiling reached (${this.deps.config.agent.maxIterations})`;
|
|
135
|
+
this.deps.renderer?.note(`✗ subagent stopped (budget): ${label}`);
|
|
136
|
+
return {
|
|
137
|
+
status: "budget-exceeded",
|
|
138
|
+
...base,
|
|
139
|
+
error: `${ErrorCode.SubagentBudget}: ${reason}`,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/** One-line task excerpt for chrome (live phase, trail notes). */
|
|
144
|
+
function taskLabel(task) {
|
|
145
|
+
const flat = task.replace(/\s+/g, " ").trim();
|
|
146
|
+
return flat.length > LABEL_MAX ? flat.slice(0, LABEL_MAX - 1) + "…" : flat;
|
|
147
|
+
}
|
|
148
|
+
/** `artifacts` only when non-empty — absent beats `[]` in the parent's context. */
|
|
149
|
+
function artifactsField(artifacts) {
|
|
150
|
+
return artifacts.size > 0 ? { artifacts: [...artifacts].sort() } : {};
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Record the paths an *approved* mutating action targets, project-relative.
|
|
154
|
+
* Shell commands carry no per-file attribution (same limitation C.32 records);
|
|
155
|
+
* they contribute no artifact entries.
|
|
156
|
+
*/
|
|
157
|
+
function recordArtifacts(action, artifacts, cwd) {
|
|
158
|
+
if (action.path) {
|
|
159
|
+
artifacts.add(path.relative(cwd, action.path) || action.path);
|
|
160
|
+
}
|
|
161
|
+
if (action.preview?.type === "patch") {
|
|
162
|
+
for (const file of action.preview.files)
|
|
163
|
+
artifacts.add(file.path);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/** The child's final assistant text — the summary the parent receives. */
|
|
167
|
+
function lastAssistantText(messages) {
|
|
168
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
169
|
+
const msg = messages[i];
|
|
170
|
+
if (msg.role !== "assistant")
|
|
171
|
+
continue;
|
|
172
|
+
if (typeof msg.content === "string") {
|
|
173
|
+
if (msg.content.trim())
|
|
174
|
+
return msg.content.trim();
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const text = msg.content
|
|
178
|
+
.filter((block) => block.type === "text")
|
|
179
|
+
.map((block) => block.text)
|
|
180
|
+
.join("\n")
|
|
181
|
+
.trim();
|
|
182
|
+
if (text)
|
|
183
|
+
return text;
|
|
184
|
+
}
|
|
185
|
+
return "";
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* The child's view of the ONE render path (U.4): a decorator over the parent's
|
|
189
|
+
* renderer, not a second implementation. Child assistant text is dropped — it
|
|
190
|
+
* is not user transcript; it returns to the parent as the structured summary.
|
|
191
|
+
* Child activity folds into the live region and trail with a `subagent ·`
|
|
192
|
+
* prefix, and the child's thinking phase keeps the task label on screen.
|
|
193
|
+
* Approval plumbing (`awaiting-approval`, previews, `promptResolved`) passes
|
|
194
|
+
* through untouched — the U.3 prompt renders identically inside a subagent.
|
|
195
|
+
*/
|
|
196
|
+
class SubagentRenderer {
|
|
197
|
+
caps;
|
|
198
|
+
inner;
|
|
199
|
+
label;
|
|
200
|
+
constructor(inner, label) {
|
|
201
|
+
this.inner = inner;
|
|
202
|
+
this.label = label;
|
|
203
|
+
this.caps = inner.caps;
|
|
204
|
+
}
|
|
205
|
+
/** Turn framing belongs to the parent's turn — the child's is dropped. */
|
|
206
|
+
beginTurn() { }
|
|
207
|
+
endTurn() { }
|
|
208
|
+
/** Child assistant text is result data, not transcript — dropped. */
|
|
209
|
+
write() { }
|
|
210
|
+
endSegment() { }
|
|
211
|
+
note(text) {
|
|
212
|
+
this.inner.note(`subagent · ${text}`);
|
|
213
|
+
}
|
|
214
|
+
preview(preview) {
|
|
215
|
+
this.inner.preview(preview);
|
|
216
|
+
}
|
|
217
|
+
status(text) {
|
|
218
|
+
this.inner.status(text);
|
|
219
|
+
}
|
|
220
|
+
setPhase(phase) {
|
|
221
|
+
// Keep the task on the live line while the child thinks; everything else
|
|
222
|
+
// (tool phases via toolLifecycle, approval yields) passes through.
|
|
223
|
+
if (phase?.kind === "thinking") {
|
|
224
|
+
this.inner.setPhase({ kind: "subagent", label: this.label });
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (phase === null)
|
|
228
|
+
return; // the orchestrator owns clearing
|
|
229
|
+
this.inner.setPhase(phase);
|
|
230
|
+
}
|
|
231
|
+
/** The plan executor owns the progress register (C.31) — never the child. */
|
|
232
|
+
progress() { }
|
|
233
|
+
toolLifecycle(event) {
|
|
234
|
+
this.inner.toolLifecycle({ ...event, label: `subagent · ${event.label}` });
|
|
235
|
+
}
|
|
236
|
+
promptResolved() {
|
|
237
|
+
this.inner.promptResolved();
|
|
238
|
+
}
|
|
239
|
+
/** The parent owns the renderer's lifetime. */
|
|
240
|
+
close() { }
|
|
241
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { ToolRegistry } from "../tools/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* Child tool-registry derivation (C.14). The one rule: a subagent's toolset is
|
|
4
|
+
* scoped DOWN from its parent's, never up. Enforcement is structural — the
|
|
5
|
+
* child registry is built exclusively from `parent.get(name)` lookups, so a
|
|
6
|
+
* tool the parent lacks is unobtainable, not merely denied.
|
|
7
|
+
*/
|
|
8
|
+
/** The spawn tool's registered name (excluded from every scoped child set —
|
|
9
|
+
* the orchestrator re-adds a depth-bound instance only while depth allows). */
|
|
10
|
+
export declare const SPAWN_SUBAGENT_TOOL_NAME = "spawn_subagent";
|
|
11
|
+
/**
|
|
12
|
+
* The default child toolset: read-only investigation plus skills. Mirrors the
|
|
13
|
+
* C.31 propose-phase set — no writes, no shell, no VCS unless the parent
|
|
14
|
+
* explicitly grants them at spawn (and holds them itself).
|
|
15
|
+
*/
|
|
16
|
+
export declare const DEFAULT_SUBAGENT_TOOLS: readonly string[];
|
|
17
|
+
/**
|
|
18
|
+
* Build a child registry as a strict subset of `parent`.
|
|
19
|
+
*
|
|
20
|
+
* - No `requested` → the intersection of {@link DEFAULT_SUBAGENT_TOOLS} with
|
|
21
|
+
* what the parent actually has (a scoped parent scopes its children further).
|
|
22
|
+
* - With `requested` → every name must exist in the parent; an unavailable
|
|
23
|
+
* name **throws** (fail loud — the spawn tool surfaces it to the model as a
|
|
24
|
+
* tool error so it can self-correct, exactly like invalid arguments).
|
|
25
|
+
* - `spawn_subagent` is always stripped: nesting is the orchestrator's
|
|
26
|
+
* decision (depth-capped), never a scope grant.
|
|
27
|
+
*/
|
|
28
|
+
export declare function scopeRegistry(parent: ToolRegistry, requested?: readonly string[]): ToolRegistry;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { ToolRegistry } from "../tools/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* Child tool-registry derivation (C.14). The one rule: a subagent's toolset is
|
|
4
|
+
* scoped DOWN from its parent's, never up. Enforcement is structural — the
|
|
5
|
+
* child registry is built exclusively from `parent.get(name)` lookups, so a
|
|
6
|
+
* tool the parent lacks is unobtainable, not merely denied.
|
|
7
|
+
*/
|
|
8
|
+
/** The spawn tool's registered name (excluded from every scoped child set —
|
|
9
|
+
* the orchestrator re-adds a depth-bound instance only while depth allows). */
|
|
10
|
+
export const SPAWN_SUBAGENT_TOOL_NAME = "spawn_subagent";
|
|
11
|
+
/**
|
|
12
|
+
* The default child toolset: read-only investigation plus skills. Mirrors the
|
|
13
|
+
* C.31 propose-phase set — no writes, no shell, no VCS unless the parent
|
|
14
|
+
* explicitly grants them at spawn (and holds them itself).
|
|
15
|
+
*/
|
|
16
|
+
export const DEFAULT_SUBAGENT_TOOLS = [
|
|
17
|
+
"list_files",
|
|
18
|
+
"read_file",
|
|
19
|
+
"glob",
|
|
20
|
+
"grep_files",
|
|
21
|
+
"git_status",
|
|
22
|
+
"search_codebase",
|
|
23
|
+
"list_skills",
|
|
24
|
+
"load_skill",
|
|
25
|
+
];
|
|
26
|
+
/**
|
|
27
|
+
* Build a child registry as a strict subset of `parent`.
|
|
28
|
+
*
|
|
29
|
+
* - No `requested` → the intersection of {@link DEFAULT_SUBAGENT_TOOLS} with
|
|
30
|
+
* what the parent actually has (a scoped parent scopes its children further).
|
|
31
|
+
* - With `requested` → every name must exist in the parent; an unavailable
|
|
32
|
+
* name **throws** (fail loud — the spawn tool surfaces it to the model as a
|
|
33
|
+
* tool error so it can self-correct, exactly like invalid arguments).
|
|
34
|
+
* - `spawn_subagent` is always stripped: nesting is the orchestrator's
|
|
35
|
+
* decision (depth-capped), never a scope grant.
|
|
36
|
+
*/
|
|
37
|
+
export function scopeRegistry(parent, requested) {
|
|
38
|
+
const child = new ToolRegistry();
|
|
39
|
+
if (requested === undefined) {
|
|
40
|
+
for (const name of DEFAULT_SUBAGENT_TOOLS) {
|
|
41
|
+
const tool = parent.get(name);
|
|
42
|
+
if (tool)
|
|
43
|
+
child.register(tool);
|
|
44
|
+
}
|
|
45
|
+
return child;
|
|
46
|
+
}
|
|
47
|
+
for (const name of new Set(requested)) {
|
|
48
|
+
if (name === SPAWN_SUBAGENT_TOOL_NAME)
|
|
49
|
+
continue;
|
|
50
|
+
const tool = parent.get(name);
|
|
51
|
+
if (!tool) {
|
|
52
|
+
const available = parent
|
|
53
|
+
.list()
|
|
54
|
+
.map((t) => t.name)
|
|
55
|
+
.filter((n) => n !== SPAWN_SUBAGENT_TOOL_NAME)
|
|
56
|
+
.join(", ");
|
|
57
|
+
throw new Error(`tool "${name}" is not available to grant a subagent (a subagent's tools ` +
|
|
58
|
+
`must be a subset of yours). Available: ${available}`);
|
|
59
|
+
}
|
|
60
|
+
child.register(tool);
|
|
61
|
+
}
|
|
62
|
+
return child;
|
|
63
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { Tool } from "../tools/index.js";
|
|
3
|
+
import type { SubagentOrchestrator } from "./orchestrator.js";
|
|
4
|
+
/**
|
|
5
|
+
* The `spawn_subagent` tool (C.14): the parent-facing seam for delegation. A
|
|
6
|
+
* normal tool on the same loop as everything else — no hidden control flow.
|
|
7
|
+
* Bound to the spawner's depth at construction, so the registry a tool lives
|
|
8
|
+
* on structurally encodes how much deeper nesting may go: at the configured
|
|
9
|
+
* cap the tool simply isn't registered.
|
|
10
|
+
*/
|
|
11
|
+
declare const parameters: z.ZodObject<{
|
|
12
|
+
task: z.ZodString;
|
|
13
|
+
tools: z.ZodOptional<z.ZodArray<z.ZodString, "atleastone">>;
|
|
14
|
+
maxIterations: z.ZodOptional<z.ZodNumber>;
|
|
15
|
+
maxTokens: z.ZodOptional<z.ZodNumber>;
|
|
16
|
+
}, "strip", z.ZodTypeAny, {
|
|
17
|
+
task: string;
|
|
18
|
+
maxTokens?: number | undefined;
|
|
19
|
+
maxIterations?: number | undefined;
|
|
20
|
+
tools?: [string, ...string[]] | undefined;
|
|
21
|
+
}, {
|
|
22
|
+
task: string;
|
|
23
|
+
maxTokens?: number | undefined;
|
|
24
|
+
maxIterations?: number | undefined;
|
|
25
|
+
tools?: [string, ...string[]] | undefined;
|
|
26
|
+
}>;
|
|
27
|
+
/** Build a `spawn_subagent` tool bound to `orchestrator` at `depth`. */
|
|
28
|
+
export declare function makeSpawnSubagentTool(orchestrator: SubagentOrchestrator, depth: number): Tool<typeof parameters>;
|
|
29
|
+
export {};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { CruxyError, ErrorCode } from "../errors/index.js";
|
|
3
|
+
import { SPAWN_SUBAGENT_TOOL_NAME } from "./registry-scope.js";
|
|
4
|
+
/**
|
|
5
|
+
* The `spawn_subagent` tool (C.14): the parent-facing seam for delegation. A
|
|
6
|
+
* normal tool on the same loop as everything else — no hidden control flow.
|
|
7
|
+
* Bound to the spawner's depth at construction, so the registry a tool lives
|
|
8
|
+
* on structurally encodes how much deeper nesting may go: at the configured
|
|
9
|
+
* cap the tool simply isn't registered.
|
|
10
|
+
*/
|
|
11
|
+
const parameters = z.object({
|
|
12
|
+
task: z
|
|
13
|
+
.string()
|
|
14
|
+
.min(1)
|
|
15
|
+
.describe("The complete, self-contained subtask. The subagent starts with NO context " +
|
|
16
|
+
"beyond this text — include every path, constraint, and expected output."),
|
|
17
|
+
tools: z
|
|
18
|
+
.array(z.string().min(1))
|
|
19
|
+
.nonempty()
|
|
20
|
+
.optional()
|
|
21
|
+
.describe("Tool names to grant the subagent — must be a subset of your own tools. " +
|
|
22
|
+
"Omit for the default read-only set. Grant write/shell tools only when " +
|
|
23
|
+
"the subtask needs them; every side effect still requires user approval."),
|
|
24
|
+
maxIterations: z
|
|
25
|
+
.number()
|
|
26
|
+
.int()
|
|
27
|
+
.positive()
|
|
28
|
+
.optional()
|
|
29
|
+
.describe("Cap on the subagent's model turns (clamped to the configured ceiling)."),
|
|
30
|
+
maxTokens: z
|
|
31
|
+
.number()
|
|
32
|
+
.int()
|
|
33
|
+
.positive()
|
|
34
|
+
.optional()
|
|
35
|
+
.describe("Cap on the subagent's total tokens (clamped to the configured ceiling)."),
|
|
36
|
+
});
|
|
37
|
+
/** The compact wire shape fed back to the parent model. */
|
|
38
|
+
function renderResult(result) {
|
|
39
|
+
return JSON.stringify({
|
|
40
|
+
status: result.status,
|
|
41
|
+
summary: result.summary,
|
|
42
|
+
...(result.artifacts ? { artifacts: result.artifacts } : {}),
|
|
43
|
+
...(result.error ? { error: result.error } : {}),
|
|
44
|
+
iterations: result.iterations,
|
|
45
|
+
tokens: {
|
|
46
|
+
input: result.usage.input_tokens,
|
|
47
|
+
output: result.usage.output_tokens,
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
/** Build a `spawn_subagent` tool bound to `orchestrator` at `depth`. */
|
|
52
|
+
export function makeSpawnSubagentTool(orchestrator, depth) {
|
|
53
|
+
return {
|
|
54
|
+
name: SPAWN_SUBAGENT_TOOL_NAME,
|
|
55
|
+
description: "Delegate a bounded, self-contained subtask to a scoped subagent with its own " +
|
|
56
|
+
"fresh context, a restricted toolset, and a hard budget. It runs to completion " +
|
|
57
|
+
"and returns only a structured result (status, summary, artifacts) — its " +
|
|
58
|
+
"transcript is discarded. Use for independent subtasks whose details you don't " +
|
|
59
|
+
'need in your own context (e.g. "find where X is configured and report the paths").',
|
|
60
|
+
parameters,
|
|
61
|
+
async execute(input) {
|
|
62
|
+
const budget = {
|
|
63
|
+
...(input.maxIterations !== undefined
|
|
64
|
+
? { maxIterations: input.maxIterations }
|
|
65
|
+
: {}),
|
|
66
|
+
...(input.maxTokens !== undefined
|
|
67
|
+
? { maxTokens: input.maxTokens }
|
|
68
|
+
: {}),
|
|
69
|
+
};
|
|
70
|
+
let result;
|
|
71
|
+
try {
|
|
72
|
+
result = await orchestrator.spawn({ task: input.task, tools: input.tools, budget }, depth);
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
// Non-interactive default-deny propagates to the boundary (U.3) —
|
|
76
|
+
// same behavior as every other gated tool.
|
|
77
|
+
if (CruxyError.is(err) && err.code === ErrorCode.ApprovalRequired) {
|
|
78
|
+
throw err;
|
|
79
|
+
}
|
|
80
|
+
// Depth-exceed and scope violations are the model's to correct: feed
|
|
81
|
+
// the coded, actionable message back as a tool error.
|
|
82
|
+
const message = CruxyError.is(err)
|
|
83
|
+
? `${err.code}: ${err.title}${err.cause ? ` — ${err.cause}` : ""}`
|
|
84
|
+
: err.message;
|
|
85
|
+
return { ok: false, error: message };
|
|
86
|
+
}
|
|
87
|
+
// A failed child is an is_error result (strong signal), still structured;
|
|
88
|
+
// budget-exceeded is informational — a partial result, not an error.
|
|
89
|
+
return result.status === "failed"
|
|
90
|
+
? { ok: false, error: renderResult(result) }
|
|
91
|
+
: { ok: true, output: renderResult(result) };
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { Usage } from "@cruxy/sdk";
|
|
2
|
+
/**
|
|
3
|
+
* Types for subagent orchestration (C.14): the main agent delegates a bounded
|
|
4
|
+
* subtask to a child agent that runs the SAME loop with its own fresh history,
|
|
5
|
+
* a scoped-down tool registry, and a hard budget — then returns a structured
|
|
6
|
+
* result and discards its transcript. The parent reasons over the result only;
|
|
7
|
+
* context isolation is the whole point.
|
|
8
|
+
*/
|
|
9
|
+
/** Why a subagent run ended. Every path returns a result — never a hang. */
|
|
10
|
+
export type SubagentStatus = "done" | "budget-exceeded" | "failed";
|
|
11
|
+
/**
|
|
12
|
+
* Hard caps a subagent runs under. `maxIterations` and `maxTokens` are always
|
|
13
|
+
* finite — a subagent is bounded by construction; `timeoutMs` is an optional
|
|
14
|
+
* wall-clock backstop on top.
|
|
15
|
+
*/
|
|
16
|
+
export interface BudgetLimits {
|
|
17
|
+
/** Cap on the subagent's model turns. */
|
|
18
|
+
maxIterations: number;
|
|
19
|
+
/** Cap on the subagent's combined input+output tokens. */
|
|
20
|
+
maxTokens: number;
|
|
21
|
+
/** Optional wall-clock cap in milliseconds. */
|
|
22
|
+
timeoutMs?: number;
|
|
23
|
+
}
|
|
24
|
+
/** A spawn request: the bounded task plus optional scope/budget narrowing. */
|
|
25
|
+
export interface SubagentSpec {
|
|
26
|
+
/** The complete, self-contained subtask the subagent should perform. */
|
|
27
|
+
task: string;
|
|
28
|
+
/**
|
|
29
|
+
* Tool names to grant, resolved against the *parent's* registry — a subagent
|
|
30
|
+
* can only ever scope DOWN. Omitted → the default read-only set.
|
|
31
|
+
*/
|
|
32
|
+
tools?: readonly string[];
|
|
33
|
+
/**
|
|
34
|
+
* Budget overrides. Clamped to `subagent.defaultBudget` from config — a
|
|
35
|
+
* spawn can narrow its budget, never raise it past the configured ceilings.
|
|
36
|
+
*/
|
|
37
|
+
budget?: Partial<BudgetLimits>;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* What the parent gets back — compact structured data, never the transcript.
|
|
41
|
+
* `summary` is the subagent's final assistant message (partial when a budget
|
|
42
|
+
* tripped); `artifacts` are the project-relative paths the subagent was
|
|
43
|
+
* approved to mutate.
|
|
44
|
+
*/
|
|
45
|
+
export interface SubagentResult {
|
|
46
|
+
status: SubagentStatus;
|
|
47
|
+
summary: string;
|
|
48
|
+
artifacts?: string[];
|
|
49
|
+
/** Present on `budget-exceeded` / `failed`: the coded, actionable reason. */
|
|
50
|
+
error?: string;
|
|
51
|
+
/** Model turns the subagent consumed. */
|
|
52
|
+
iterations: number;
|
|
53
|
+
/** Token usage the subagent consumed (folded into the parent's accounting). */
|
|
54
|
+
usage: Usage;
|
|
55
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/tools/types.d.ts
CHANGED
|
@@ -76,6 +76,24 @@ export type ActionPreview =
|
|
|
76
76
|
commitBody: string;
|
|
77
77
|
prTitle: string;
|
|
78
78
|
prBody: string;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The full blast radius of a checkpoint restore (C.32): every file rollback
|
|
82
|
+
* will recreate/revert/delete (as patch-style entries), plus the paths that
|
|
83
|
+
* changed since the checkpoint by something *other* than the tracked run —
|
|
84
|
+
* shown before the diff so the collapse cap can never hide them. Rollback
|
|
85
|
+
* covers working-tree files only; commits/pushes/PRs made during the run are
|
|
86
|
+
* out of scope (stated in the rendered note).
|
|
87
|
+
*/
|
|
88
|
+
| {
|
|
89
|
+
type: "rollback";
|
|
90
|
+
checkpointId: string;
|
|
91
|
+
createdAt: string;
|
|
92
|
+
runSummary: string;
|
|
93
|
+
files: PatchFilePreview[];
|
|
94
|
+
externalPaths: string[];
|
|
95
|
+
/** The run executed shell commands, so per-file attribution is impossible. */
|
|
96
|
+
attributionUnknown: boolean;
|
|
79
97
|
};
|
|
80
98
|
/**
|
|
81
99
|
* A side-effecting action a tool wants to take, passed to `ctx.approve`. The
|
|
@@ -83,12 +101,12 @@ export type ActionPreview =
|
|
|
83
101
|
*/
|
|
84
102
|
export interface ApproveAction {
|
|
85
103
|
/** The category of side effect being requested. */
|
|
86
|
-
kind: "write" | "edit" | "shell" | "patch" | "vcs";
|
|
104
|
+
kind: "write" | "edit" | "shell" | "patch" | "vcs" | "rollback";
|
|
87
105
|
/** Absolute resolved path the action targets (write/edit). */
|
|
88
106
|
path?: string;
|
|
89
107
|
/** The command to run (shell). */
|
|
90
108
|
command?: string;
|
|
91
|
-
/** Exact-change preview rendered above the prompt (write/edit/patch/vcs). */
|
|
109
|
+
/** Exact-change preview rendered above the prompt (write/edit/patch/vcs/rollback). */
|
|
92
110
|
preview?: ActionPreview;
|
|
93
111
|
}
|
|
94
112
|
/**
|