neuro-cli 4.1.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/LICENSE +21 -0
- package/README.md +510 -0
- package/dist/advisor/advisor.d.ts +50 -0
- package/dist/advisor/advisor.js +178 -0
- package/dist/agents/base.d.ts +62 -0
- package/dist/agents/base.js +215 -0
- package/dist/agents/orchestrator.d.ts +46 -0
- package/dist/agents/orchestrator.js +192 -0
- package/dist/agents/team.d.ts +51 -0
- package/dist/agents/team.js +210 -0
- package/dist/api/models.d.ts +23 -0
- package/dist/api/models.js +514 -0
- package/dist/api/ollama.d.ts +153 -0
- package/dist/api/ollama.js +751 -0
- package/dist/api/openrouter.d.ts +55 -0
- package/dist/api/openrouter.js +223 -0
- package/dist/commands/commands.d.ts +43 -0
- package/dist/commands/commands.js +308 -0
- package/dist/config/config.d.ts +8 -0
- package/dist/config/config.js +311 -0
- package/dist/context/compaction.d.ts +54 -0
- package/dist/context/compaction.js +251 -0
- package/dist/context/custom-agents.d.ts +107 -0
- package/dist/context/custom-agents.js +397 -0
- package/dist/context/custom-tools.d.ts +120 -0
- package/dist/context/custom-tools.js +564 -0
- package/dist/context/git-checkpoint.d.ts +52 -0
- package/dist/context/git-checkpoint.js +240 -0
- package/dist/context/neuro-md.d.ts +48 -0
- package/dist/context/neuro-md.js +202 -0
- package/dist/context/neuroignore.d.ts +102 -0
- package/dist/context/neuroignore.js +441 -0
- package/dist/context/repo-map.d.ts +38 -0
- package/dist/context/repo-map.js +220 -0
- package/dist/context/skill-standard.d.ts +262 -0
- package/dist/context/skill-standard.js +1156 -0
- package/dist/context/skill-system.d.ts +75 -0
- package/dist/context/skill-system.js +578 -0
- package/dist/context/tree-sitter.d.ts +99 -0
- package/dist/context/tree-sitter.js +1956 -0
- package/dist/core/acp.d.ts +325 -0
- package/dist/core/acp.js +1498 -0
- package/dist/core/api-server.d.ts +143 -0
- package/dist/core/api-server.js +550 -0
- package/dist/core/approval.d.ts +81 -0
- package/dist/core/approval.js +432 -0
- package/dist/core/auto-compact.d.ts +127 -0
- package/dist/core/auto-compact.js +436 -0
- package/dist/core/auto-mode.d.ts +232 -0
- package/dist/core/auto-mode.js +831 -0
- package/dist/core/background-session.d.ts +166 -0
- package/dist/core/background-session.js +696 -0
- package/dist/core/cicd.d.ts +257 -0
- package/dist/core/cicd.js +1443 -0
- package/dist/core/cloud-sync.d.ts +156 -0
- package/dist/core/cloud-sync.js +582 -0
- package/dist/core/code-review.d.ts +132 -0
- package/dist/core/code-review.js +1191 -0
- package/dist/core/completion.d.ts +49 -0
- package/dist/core/completion.js +384 -0
- package/dist/core/context.d.ts +38 -0
- package/dist/core/context.js +144 -0
- package/dist/core/diff-preview.d.ts +35 -0
- package/dist/core/diff-preview.js +173 -0
- package/dist/core/doom-loop.d.ts +51 -0
- package/dist/core/doom-loop.js +179 -0
- package/dist/core/engine.d.ts +183 -0
- package/dist/core/engine.js +942 -0
- package/dist/core/extended-thinking.d.ts +103 -0
- package/dist/core/extended-thinking.js +269 -0
- package/dist/core/fallback.d.ts +54 -0
- package/dist/core/fallback.js +104 -0
- package/dist/core/git-worktree.d.ts +139 -0
- package/dist/core/git-worktree.js +614 -0
- package/dist/core/headless.d.ts +30 -0
- package/dist/core/headless.js +101 -0
- package/dist/core/i18n.d.ts +83 -0
- package/dist/core/i18n.js +739 -0
- package/dist/core/linting.d.ts +129 -0
- package/dist/core/linting.js +699 -0
- package/dist/core/model-router.d.ts +109 -0
- package/dist/core/model-router.js +581 -0
- package/dist/core/multi-model.d.ts +243 -0
- package/dist/core/multi-model.js +1099 -0
- package/dist/core/multi-session.d.ts +144 -0
- package/dist/core/multi-session.js +442 -0
- package/dist/core/multimodal.d.ts +125 -0
- package/dist/core/multimodal.js +286 -0
- package/dist/core/observability.d.ts +93 -0
- package/dist/core/observability.js +737 -0
- package/dist/core/os-sandbox.d.ts +122 -0
- package/dist/core/os-sandbox.js +1193 -0
- package/dist/core/outcome-grading.d.ts +228 -0
- package/dist/core/outcome-grading.js +1123 -0
- package/dist/core/output-styles.d.ts +57 -0
- package/dist/core/output-styles.js +382 -0
- package/dist/core/parallel-agents.d.ts +183 -0
- package/dist/core/parallel-agents.js +563 -0
- package/dist/core/plugin-bundle.d.ts +236 -0
- package/dist/core/plugin-bundle.js +887 -0
- package/dist/core/plugin-sdk.d.ts +139 -0
- package/dist/core/plugin-sdk.js +273 -0
- package/dist/core/prompt-cache.d.ts +163 -0
- package/dist/core/prompt-cache.js +599 -0
- package/dist/core/sandbox.d.ts +127 -0
- package/dist/core/sandbox.js +369 -0
- package/dist/core/scheduled-tasks.d.ts +151 -0
- package/dist/core/scheduled-tasks.js +502 -0
- package/dist/core/security-scanner.d.ts +160 -0
- package/dist/core/security-scanner.js +1494 -0
- package/dist/core/session.d.ts +83 -0
- package/dist/core/session.js +269 -0
- package/dist/core/shell-completion.d.ts +51 -0
- package/dist/core/shell-completion.js +674 -0
- package/dist/core/smart-monitor.d.ts +146 -0
- package/dist/core/smart-monitor.js +1199 -0
- package/dist/core/spec-driven.d.ts +233 -0
- package/dist/core/spec-driven.js +1485 -0
- package/dist/core/spending-warnings.d.ts +123 -0
- package/dist/core/spending-warnings.js +456 -0
- package/dist/core/sub-agent.d.ts +298 -0
- package/dist/core/sub-agent.js +1023 -0
- package/dist/core/telemetry.d.ts +157 -0
- package/dist/core/telemetry.js +412 -0
- package/dist/core/terminal-ux.d.ts +134 -0
- package/dist/core/terminal-ux.js +649 -0
- package/dist/core/testing.d.ts +146 -0
- package/dist/core/testing.js +1200 -0
- package/dist/core/types.d.ts +439 -0
- package/dist/core/types.js +6 -0
- package/dist/core/undo-redo.d.ts +112 -0
- package/dist/core/undo-redo.js +290 -0
- package/dist/core/updater.d.ts +159 -0
- package/dist/core/updater.js +608 -0
- package/dist/core/vim-mode.d.ts +151 -0
- package/dist/core/vim-mode.js +771 -0
- package/dist/core/voice.d.ts +137 -0
- package/dist/core/voice.js +538 -0
- package/dist/core/web-dashboard.d.ts +109 -0
- package/dist/core/web-dashboard.js +484 -0
- package/dist/hooks/hooks.d.ts +74 -0
- package/dist/hooks/hooks.js +160 -0
- package/dist/hooks/llm-evaluator.d.ts +165 -0
- package/dist/hooks/llm-evaluator.js +560 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1186 -0
- package/dist/lsp/lsp-manager.d.ts +63 -0
- package/dist/lsp/lsp-manager.js +351 -0
- package/dist/mcp/client.d.ts +133 -0
- package/dist/mcp/client.js +684 -0
- package/dist/mcp/mcp-apps.d.ts +70 -0
- package/dist/mcp/mcp-apps.js +1007 -0
- package/dist/tools/bash.d.ts +5 -0
- package/dist/tools/bash.js +195 -0
- package/dist/tools/browser.d.ts +92 -0
- package/dist/tools/browser.js +1570 -0
- package/dist/tools/extended.d.ts +6 -0
- package/dist/tools/extended.js +191 -0
- package/dist/tools/file.d.ts +10 -0
- package/dist/tools/file.js +382 -0
- package/dist/tools/github.d.ts +389 -0
- package/dist/tools/github.js +759 -0
- package/dist/tools/index.d.ts +9 -0
- package/dist/tools/index.js +40 -0
- package/dist/tools/memory.d.ts +6 -0
- package/dist/tools/memory.js +197 -0
- package/dist/tools/registry.d.ts +29 -0
- package/dist/tools/registry.js +64 -0
- package/dist/tools/web.d.ts +6 -0
- package/dist/tools/web.js +150 -0
- package/dist/ui/renderer.d.ts +97 -0
- package/dist/ui/renderer.js +279 -0
- package/dist/ui/theme.d.ts +27 -0
- package/dist/ui/theme.js +106 -0
- package/package.json +83 -0
|
@@ -0,0 +1,1023 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// NeuroCLI - Sub-Agent Spawning with Scoped Tool Access (GAP-27)
|
|
3
|
+
// Enables parent agents to spawn child sub-agents with
|
|
4
|
+
// restricted tool access, file scope, resource limits,
|
|
5
|
+
// hierarchical delegation, and structured result handoff.
|
|
6
|
+
// Inspired by Claude Code's Task tool and Cursor sub-agents.
|
|
7
|
+
// Uses only Node.js built-in modules + existing project deps.
|
|
8
|
+
// ============================================================
|
|
9
|
+
import { EventEmitter } from 'events';
|
|
10
|
+
import { resolve, relative, isAbsolute } from 'path';
|
|
11
|
+
import { existsSync, readFileSync, statSync, readdirSync } from 'fs';
|
|
12
|
+
import { ToolRegistry } from '../tools/registry.js';
|
|
13
|
+
import { BaseAgent } from '../agents/base.js';
|
|
14
|
+
// ============================================================
|
|
15
|
+
// Constants
|
|
16
|
+
// ============================================================
|
|
17
|
+
/** Default maximum nesting depth for sub-agents */
|
|
18
|
+
const DEFAULT_MAX_DEPTH = 3;
|
|
19
|
+
/** Default resource limits if not specified */
|
|
20
|
+
const DEFAULT_RESOURCE_LIMITS = {
|
|
21
|
+
maxTokens: 100_000,
|
|
22
|
+
maxTurns: 20,
|
|
23
|
+
timeoutMs: 120_000, // 2 minutes
|
|
24
|
+
};
|
|
25
|
+
/** Names of tools that involve file I/O (used for file scope wrapping) */
|
|
26
|
+
const FILE_TOOL_NAMES = new Set([
|
|
27
|
+
'read_file',
|
|
28
|
+
'write_file',
|
|
29
|
+
'edit_file',
|
|
30
|
+
'list_directory',
|
|
31
|
+
'search_files',
|
|
32
|
+
'create_directory',
|
|
33
|
+
'move_file',
|
|
34
|
+
'delete_file',
|
|
35
|
+
'file_stats',
|
|
36
|
+
]);
|
|
37
|
+
// ============================================================
|
|
38
|
+
// ScopedToolRegistry
|
|
39
|
+
// ============================================================
|
|
40
|
+
/**
|
|
41
|
+
* A filtered view of a ToolRegistry that only exposes tools
|
|
42
|
+
* permitted by the sub-agent's allowlist/denylist, and wraps
|
|
43
|
+
* file tools with path validation for file scope enforcement.
|
|
44
|
+
*/
|
|
45
|
+
class ScopedToolRegistry extends ToolRegistry {
|
|
46
|
+
allowedSet;
|
|
47
|
+
deniedSet;
|
|
48
|
+
fileScopeAllowed;
|
|
49
|
+
fileScopeDenied;
|
|
50
|
+
fileScopeEnabled;
|
|
51
|
+
workingDir;
|
|
52
|
+
constructor(parentRegistry, allowedTools, deniedTools, fileScope, workingDir) {
|
|
53
|
+
super();
|
|
54
|
+
this.allowedSet = new Set(allowedTools);
|
|
55
|
+
this.deniedSet = new Set(deniedTools);
|
|
56
|
+
this.workingDir = workingDir;
|
|
57
|
+
// Resolve file scope paths to absolute
|
|
58
|
+
this.fileScopeAllowed = new Set((fileScope?.allowedPaths ?? []).map((p) => isAbsolute(p) ? p : resolve(workingDir, p)));
|
|
59
|
+
this.fileScopeDenied = new Set((fileScope?.deniedPaths ?? []).map((p) => isAbsolute(p) ? p : resolve(workingDir, p)));
|
|
60
|
+
this.fileScopeEnabled = this.fileScopeAllowed.size > 0 || this.fileScopeDenied.size > 0;
|
|
61
|
+
// Copy permitted tools from parent, wrapping file tools if needed
|
|
62
|
+
for (const tool of parentRegistry.getAll()) {
|
|
63
|
+
if (!this.allowedSet.has(tool.name))
|
|
64
|
+
continue;
|
|
65
|
+
if (this.deniedSet.has(tool.name))
|
|
66
|
+
continue;
|
|
67
|
+
if (this.fileScopeEnabled && FILE_TOOL_NAMES.has(tool.name)) {
|
|
68
|
+
this.register(this.wrapFileTool(tool));
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
this.register(tool);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Wrap a file tool executor with path validation.
|
|
77
|
+
* If a file path argument is outside the allowed scope or
|
|
78
|
+
* inside the denied scope, the tool returns an error instead
|
|
79
|
+
* of executing.
|
|
80
|
+
*/
|
|
81
|
+
wrapFileTool(tool) {
|
|
82
|
+
const originalExecute = tool.execute.bind(tool);
|
|
83
|
+
const scopeAllowed = this.fileScopeAllowed;
|
|
84
|
+
const scopeDenied = this.fileScopeDenied;
|
|
85
|
+
const workingDir = this.workingDir;
|
|
86
|
+
const wrappedExecute = async (args, context) => {
|
|
87
|
+
// Find the file path argument — tools use various arg names
|
|
88
|
+
const filePath = this.extractFilePath(args);
|
|
89
|
+
if (filePath !== null) {
|
|
90
|
+
const absolutePath = isAbsolute(filePath) ? filePath : resolve(workingDir, filePath);
|
|
91
|
+
// Check denied paths first (denied takes priority)
|
|
92
|
+
for (const denied of scopeDenied) {
|
|
93
|
+
if (absolutePath.startsWith(denied) || absolutePath === denied) {
|
|
94
|
+
return `ERROR: Access denied — path "${filePath}" is outside the allowed file scope.`;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// If allowed paths are specified, the path must be under one of them
|
|
98
|
+
if (scopeAllowed.size > 0) {
|
|
99
|
+
let isAllowed = false;
|
|
100
|
+
for (const allowed of scopeAllowed) {
|
|
101
|
+
if (absolutePath.startsWith(allowed) || absolutePath === allowed) {
|
|
102
|
+
isAllowed = true;
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (!isAllowed) {
|
|
107
|
+
return `ERROR: Access denied — path "${filePath}" is outside the allowed file scope.`;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return originalExecute(args, context);
|
|
112
|
+
};
|
|
113
|
+
return {
|
|
114
|
+
name: tool.name,
|
|
115
|
+
definition: tool.definition,
|
|
116
|
+
description: tool.description,
|
|
117
|
+
parameters: tool.parameters,
|
|
118
|
+
execute: wrappedExecute,
|
|
119
|
+
getApprovalRequest: tool.getApprovalRequest?.bind(tool),
|
|
120
|
+
risk: tool.risk,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Extract a file path from tool arguments.
|
|
125
|
+
* Different tools use different argument names for paths.
|
|
126
|
+
*/
|
|
127
|
+
extractFilePath(args) {
|
|
128
|
+
const pathKeys = ['path', 'filePath', 'file_path', 'source', 'directory', 'dir'];
|
|
129
|
+
for (const key of pathKeys) {
|
|
130
|
+
if (typeof args[key] === 'string' && args[key].length > 0) {
|
|
131
|
+
return args[key];
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// Check for nested destinations (e.g., move_file has source + destination)
|
|
135
|
+
if (typeof args['destination'] === 'string') {
|
|
136
|
+
return args['destination'];
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// ============================================================
|
|
142
|
+
// FileSnapshot
|
|
143
|
+
// ============================================================
|
|
144
|
+
/**
|
|
145
|
+
* Simple file snapshot utility that records file content hashes
|
|
146
|
+
* before sub-agent execution and compares afterwards to detect
|
|
147
|
+
* what files were modified.
|
|
148
|
+
*/
|
|
149
|
+
class FileSnapshot {
|
|
150
|
+
workingDir;
|
|
151
|
+
snapshot = new Map();
|
|
152
|
+
scopedPaths;
|
|
153
|
+
constructor(workingDir, fileScope) {
|
|
154
|
+
this.workingDir = workingDir;
|
|
155
|
+
if (fileScope && fileScope.allowedPaths.length > 0) {
|
|
156
|
+
this.scopedPaths = fileScope.allowedPaths.map((p) => isAbsolute(p) ? p : resolve(workingDir, p));
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
this.scopedPaths = [workingDir];
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Take a snapshot of all files in the scoped paths.
|
|
164
|
+
* Uses a fast hash (simple content length + first/last bytes) for
|
|
165
|
+
* comparison purposes. Does not follow symlinks or descend into
|
|
166
|
+
* node_modules / .git directories.
|
|
167
|
+
*/
|
|
168
|
+
take() {
|
|
169
|
+
this.snapshot = new Map();
|
|
170
|
+
for (const scopedPath of this.scopedPaths) {
|
|
171
|
+
this.walkDir(scopedPath);
|
|
172
|
+
}
|
|
173
|
+
return new Map(this.snapshot);
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Compare the current file state against the snapshot and
|
|
177
|
+
* return a list of detected changes.
|
|
178
|
+
*/
|
|
179
|
+
diff() {
|
|
180
|
+
const changes = [];
|
|
181
|
+
const currentSnapshot = new Map();
|
|
182
|
+
// Re-scan the same directories
|
|
183
|
+
for (const scopedPath of this.scopedPaths) {
|
|
184
|
+
this.walkDirInto(scopedPath, currentSnapshot);
|
|
185
|
+
}
|
|
186
|
+
// Find modified and deleted files (present in original snapshot)
|
|
187
|
+
for (const [filePath, originalHash] of this.snapshot) {
|
|
188
|
+
const currentHash = currentSnapshot.get(filePath);
|
|
189
|
+
if (currentHash === undefined) {
|
|
190
|
+
changes.push({ path: filePath, type: 'delete' });
|
|
191
|
+
}
|
|
192
|
+
else if (currentHash !== originalHash) {
|
|
193
|
+
changes.push({ path: filePath, type: 'modify' });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
// Find newly created files (present in current but not original)
|
|
197
|
+
for (const [filePath] of currentSnapshot) {
|
|
198
|
+
if (!this.snapshot.has(filePath)) {
|
|
199
|
+
changes.push({ path: filePath, type: 'create' });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return changes;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Recursively walk a directory and record file hashes.
|
|
206
|
+
*/
|
|
207
|
+
walkDir(dirPath) {
|
|
208
|
+
this.walkDirInto(dirPath, this.snapshot);
|
|
209
|
+
}
|
|
210
|
+
walkDirInto(dirPath, target) {
|
|
211
|
+
if (!existsSync(dirPath))
|
|
212
|
+
return;
|
|
213
|
+
let entries;
|
|
214
|
+
try {
|
|
215
|
+
entries = readdirSync(dirPath, { withFileTypes: true });
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return; // Permission denied or similar
|
|
219
|
+
}
|
|
220
|
+
for (const entry of entries) {
|
|
221
|
+
// Skip common non-essential directories
|
|
222
|
+
if (entry.isDirectory()) {
|
|
223
|
+
if (entry.name === 'node_modules' ||
|
|
224
|
+
entry.name === '.git' ||
|
|
225
|
+
entry.name === '.next' ||
|
|
226
|
+
entry.name === 'dist' ||
|
|
227
|
+
entry.name === '.turbo') {
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
this.walkDirInto(resolve(dirPath, entry.name), target);
|
|
231
|
+
}
|
|
232
|
+
else if (entry.isFile()) {
|
|
233
|
+
const fullPath = resolve(dirPath, entry.name);
|
|
234
|
+
try {
|
|
235
|
+
const hash = this.quickHash(fullPath);
|
|
236
|
+
target.set(fullPath, hash);
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
// File may be unreadable; skip
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Quick content hash: uses file size + first 64 bytes + last 64 bytes.
|
|
246
|
+
* Fast and sufficient for detecting modifications without reading
|
|
247
|
+
* entire large files.
|
|
248
|
+
*/
|
|
249
|
+
quickHash(filePath) {
|
|
250
|
+
const stat = statSync(filePath);
|
|
251
|
+
const size = stat.size;
|
|
252
|
+
if (size === 0)
|
|
253
|
+
return `0:empty`;
|
|
254
|
+
const fd = [];
|
|
255
|
+
const buf = Buffer.alloc(Math.min(size, 128));
|
|
256
|
+
const fileHandle = readFileSync(filePath);
|
|
257
|
+
// Use first 64 bytes and last 64 bytes
|
|
258
|
+
const firstChunk = fileHandle.subarray(0, Math.min(64, size));
|
|
259
|
+
const lastChunk = size > 64 ? fileHandle.subarray(Math.max(0, size - 64)) : Buffer.alloc(0);
|
|
260
|
+
return `${size}:${firstChunk.toString('hex')}:${lastChunk.toString('hex')}`;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// ============================================================
|
|
264
|
+
// SubAgentManager
|
|
265
|
+
// ============================================================
|
|
266
|
+
/**
|
|
267
|
+
* Manages the lifecycle of sub-agents spawned by a parent agent.
|
|
268
|
+
*
|
|
269
|
+
* A parent agent creates a SubAgentManager with its own OpenRouterClient
|
|
270
|
+
* and ToolRegistry. When it wants to delegate work, it calls spawn()
|
|
271
|
+
* or spawnAsync() with a configuration that defines the child's
|
|
272
|
+
* permissions, tools, and resource limits.
|
|
273
|
+
*
|
|
274
|
+
* The SubAgentManager:
|
|
275
|
+
* - Creates a temporary BaseAgent with a scoped tool registry
|
|
276
|
+
* - Enforces resource limits (tokens, turns, timeout)
|
|
277
|
+
* - Tracks file modifications via before/after snapshots
|
|
278
|
+
* - Supports hierarchical delegation (sub-agents can spawn their own children)
|
|
279
|
+
* - Emits events for status changes
|
|
280
|
+
*
|
|
281
|
+
* Usage:
|
|
282
|
+
* ```typescript
|
|
283
|
+
* const manager = new SubAgentManager(client, registry, '/project', 'session-1');
|
|
284
|
+
*
|
|
285
|
+
* // Sequential: block until done
|
|
286
|
+
* const result = await manager.spawn({
|
|
287
|
+
* name: 'code-searcher',
|
|
288
|
+
* systemPrompt: 'You are a code search specialist.',
|
|
289
|
+
* allowedTools: ['search_files', 'read_file'],
|
|
290
|
+
* resourceLimits: { maxTokens: 50_000, maxTurns: 10, timeoutMs: 60_000 },
|
|
291
|
+
* maxDepth: 2,
|
|
292
|
+
* mode: 'sequential',
|
|
293
|
+
* }, 'Find all uses of the deprecated API');
|
|
294
|
+
*
|
|
295
|
+
* // Async: fire and forget, get a task ID
|
|
296
|
+
* const taskId = await manager.spawnAsync({ ... }, 'Refactor the utils');
|
|
297
|
+
* // Later...
|
|
298
|
+
* const status = manager.getStatus(taskId);
|
|
299
|
+
* const result = await manager.waitFor(taskId);
|
|
300
|
+
* ```
|
|
301
|
+
*/
|
|
302
|
+
export class SubAgentManager extends EventEmitter {
|
|
303
|
+
client;
|
|
304
|
+
parentRegistry;
|
|
305
|
+
workingDirectory;
|
|
306
|
+
sessionId;
|
|
307
|
+
defaultModel;
|
|
308
|
+
currentDepth;
|
|
309
|
+
maxGlobalDepth;
|
|
310
|
+
/** Active and completed sub-agent records */
|
|
311
|
+
records = new Map();
|
|
312
|
+
/** Monotonic counter for generating unique task IDs */
|
|
313
|
+
taskCounter = 0;
|
|
314
|
+
/**
|
|
315
|
+
* @param client The OpenRouterClient shared with the parent agent
|
|
316
|
+
* @param registry The parent's ToolRegistry (tools will be filtered for children)
|
|
317
|
+
* @param workingDir The working directory for sub-agents
|
|
318
|
+
* @param sessionId The session ID to inherit
|
|
319
|
+
* @param defaultModel The default model to use (inherited from parent)
|
|
320
|
+
* @param currentDepth Current nesting depth (0 for top-level manager)
|
|
321
|
+
* @param maxGlobalDepth Maximum allowed nesting depth across the hierarchy
|
|
322
|
+
*/
|
|
323
|
+
constructor(client, registry, workingDir, sessionId, defaultModel = 'qwen/qwen3-coder:free', currentDepth = 0, maxGlobalDepth = DEFAULT_MAX_DEPTH) {
|
|
324
|
+
super();
|
|
325
|
+
this.client = client;
|
|
326
|
+
this.parentRegistry = registry;
|
|
327
|
+
this.workingDirectory = workingDir;
|
|
328
|
+
this.sessionId = sessionId;
|
|
329
|
+
this.defaultModel = defaultModel;
|
|
330
|
+
this.currentDepth = currentDepth;
|
|
331
|
+
this.maxGlobalDepth = maxGlobalDepth;
|
|
332
|
+
}
|
|
333
|
+
// ----------------------------------------------------------
|
|
334
|
+
// Public API — Spawn
|
|
335
|
+
// ----------------------------------------------------------
|
|
336
|
+
/**
|
|
337
|
+
* Spawn a sub-agent in sequential mode and wait for its result.
|
|
338
|
+
* The parent agent blocks until the child completes (or fails/times out).
|
|
339
|
+
*
|
|
340
|
+
* @param config Configuration for the sub-agent
|
|
341
|
+
* @param prompt The task prompt to send to the sub-agent
|
|
342
|
+
* @returns Structured result from the sub-agent
|
|
343
|
+
* @throws Error if the sub-agent fails or is cancelled
|
|
344
|
+
*/
|
|
345
|
+
async spawn(config, prompt) {
|
|
346
|
+
const effectiveConfig = this.applyDefaults(config);
|
|
347
|
+
this.validateConfig(effectiveConfig);
|
|
348
|
+
const taskId = this.generateTaskId(effectiveConfig.name);
|
|
349
|
+
const record = this.createRecord(taskId, effectiveConfig, prompt);
|
|
350
|
+
this.emit('subagent:spawning', taskId, effectiveConfig.name);
|
|
351
|
+
try {
|
|
352
|
+
const result = await this.executeSubAgent(record, effectiveConfig, prompt);
|
|
353
|
+
return result;
|
|
354
|
+
}
|
|
355
|
+
catch (err) {
|
|
356
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
357
|
+
const failResult = {
|
|
358
|
+
success: false,
|
|
359
|
+
output: '',
|
|
360
|
+
filesModified: [],
|
|
361
|
+
tokensUsed: 0,
|
|
362
|
+
turnsCompleted: 0,
|
|
363
|
+
error: error.message,
|
|
364
|
+
duration: Date.now() - record.startedAt,
|
|
365
|
+
};
|
|
366
|
+
record.status = 'failed';
|
|
367
|
+
record.result = failResult;
|
|
368
|
+
record.completedAt = Date.now();
|
|
369
|
+
record.completionReject?.(error);
|
|
370
|
+
this.emit('subagent:failed', taskId, error.message);
|
|
371
|
+
return failResult;
|
|
372
|
+
}
|
|
373
|
+
finally {
|
|
374
|
+
this.records.delete(taskId);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Spawn a sub-agent in async (fire-and-forget) mode.
|
|
379
|
+
* Returns a task ID immediately; the sub-agent runs in the background.
|
|
380
|
+
* Use getStatus() to check progress and waitFor() to collect results.
|
|
381
|
+
*
|
|
382
|
+
* @param config Configuration for the sub-agent
|
|
383
|
+
* @param prompt The task prompt to send to the sub-agent
|
|
384
|
+
* @returns Unique task ID for tracking this sub-agent
|
|
385
|
+
*/
|
|
386
|
+
async spawnAsync(config, prompt) {
|
|
387
|
+
const effectiveConfig = this.applyDefaults(config);
|
|
388
|
+
this.validateConfig(effectiveConfig);
|
|
389
|
+
const taskId = this.generateTaskId(effectiveConfig.name);
|
|
390
|
+
const record = this.createRecord(taskId, effectiveConfig, prompt);
|
|
391
|
+
this.emit('subagent:spawning', taskId, effectiveConfig.name);
|
|
392
|
+
// Execute in the background (non-blocking)
|
|
393
|
+
this.executeSubAgent(record, effectiveConfig, prompt)
|
|
394
|
+
.then((result) => {
|
|
395
|
+
record.status = 'completed';
|
|
396
|
+
record.result = result;
|
|
397
|
+
record.completedAt = Date.now();
|
|
398
|
+
record.completionResolve?.(result);
|
|
399
|
+
this.emit('subagent:completed', taskId, result);
|
|
400
|
+
})
|
|
401
|
+
.catch((err) => {
|
|
402
|
+
if (record.status === 'cancelled')
|
|
403
|
+
return; // Already handled
|
|
404
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
405
|
+
record.status = 'failed';
|
|
406
|
+
record.result = {
|
|
407
|
+
success: false,
|
|
408
|
+
output: '',
|
|
409
|
+
filesModified: [],
|
|
410
|
+
tokensUsed: 0,
|
|
411
|
+
turnsCompleted: 0,
|
|
412
|
+
error: error.message,
|
|
413
|
+
duration: Date.now() - record.startedAt,
|
|
414
|
+
};
|
|
415
|
+
record.completedAt = Date.now();
|
|
416
|
+
record.completionReject?.(error);
|
|
417
|
+
this.emit('subagent:failed', taskId, error.message);
|
|
418
|
+
});
|
|
419
|
+
return taskId;
|
|
420
|
+
}
|
|
421
|
+
// ----------------------------------------------------------
|
|
422
|
+
// Public API — Status & Control
|
|
423
|
+
// ----------------------------------------------------------
|
|
424
|
+
/**
|
|
425
|
+
* Get the current status of an async sub-agent task.
|
|
426
|
+
*
|
|
427
|
+
* @param taskId The task ID returned by spawnAsync()
|
|
428
|
+
* @returns Current status, or undefined if the task doesn't exist
|
|
429
|
+
*/
|
|
430
|
+
getStatus(taskId) {
|
|
431
|
+
const record = this.records.get(taskId);
|
|
432
|
+
if (!record)
|
|
433
|
+
return undefined;
|
|
434
|
+
return record.status;
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Get detailed info about a sub-agent.
|
|
438
|
+
*
|
|
439
|
+
* @param taskId The task ID
|
|
440
|
+
* @returns Sub-agent info, or undefined if not found
|
|
441
|
+
*/
|
|
442
|
+
getInfo(taskId) {
|
|
443
|
+
const record = this.records.get(taskId);
|
|
444
|
+
if (!record)
|
|
445
|
+
return undefined;
|
|
446
|
+
return {
|
|
447
|
+
taskId: record.taskId,
|
|
448
|
+
name: record.config.name,
|
|
449
|
+
status: record.status,
|
|
450
|
+
depth: record.depth,
|
|
451
|
+
turnsCompleted: record.result?.turnsCompleted ?? 0,
|
|
452
|
+
tokensUsed: record.result?.tokensUsed ?? 0,
|
|
453
|
+
elapsedMs: Date.now() - record.startedAt,
|
|
454
|
+
parentTaskId: record.parentTaskId,
|
|
455
|
+
startedAt: record.startedAt,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Cancel a running sub-agent.
|
|
460
|
+
* The sub-agent's abort controller is triggered, causing it to
|
|
461
|
+
* stop at the next iteration boundary.
|
|
462
|
+
*
|
|
463
|
+
* @param taskId The task ID to cancel
|
|
464
|
+
* @returns True if the agent was successfully cancelled
|
|
465
|
+
*/
|
|
466
|
+
cancel(taskId) {
|
|
467
|
+
const record = this.records.get(taskId);
|
|
468
|
+
if (!record)
|
|
469
|
+
return false;
|
|
470
|
+
if (record.status === 'completed' ||
|
|
471
|
+
record.status === 'failed' ||
|
|
472
|
+
record.status === 'cancelled') {
|
|
473
|
+
return false; // Already terminal
|
|
474
|
+
}
|
|
475
|
+
record.abortController.abort();
|
|
476
|
+
record.status = 'cancelled';
|
|
477
|
+
record.completedAt = Date.now();
|
|
478
|
+
if (!record.result) {
|
|
479
|
+
record.result = {
|
|
480
|
+
success: false,
|
|
481
|
+
output: '',
|
|
482
|
+
filesModified: [],
|
|
483
|
+
tokensUsed: 0,
|
|
484
|
+
turnsCompleted: 0,
|
|
485
|
+
error: 'Cancelled by parent',
|
|
486
|
+
duration: Date.now() - record.startedAt,
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
record.completionReject?.(new Error(`Sub-agent "${taskId}" was cancelled`));
|
|
490
|
+
this.emit('subagent:cancelled', taskId);
|
|
491
|
+
return true;
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* List all active sub-agents (pending, running, or timed_out).
|
|
495
|
+
*
|
|
496
|
+
* @returns Array of sub-agent info objects
|
|
497
|
+
*/
|
|
498
|
+
list() {
|
|
499
|
+
const infos = [];
|
|
500
|
+
for (const record of this.records.values()) {
|
|
501
|
+
if (record.status === 'pending' ||
|
|
502
|
+
record.status === 'running' ||
|
|
503
|
+
record.status === 'timed_out') {
|
|
504
|
+
infos.push({
|
|
505
|
+
taskId: record.taskId,
|
|
506
|
+
name: record.config.name,
|
|
507
|
+
status: record.status,
|
|
508
|
+
depth: record.depth,
|
|
509
|
+
turnsCompleted: record.result?.turnsCompleted ?? 0,
|
|
510
|
+
tokensUsed: record.result?.tokensUsed ?? 0,
|
|
511
|
+
elapsedMs: Date.now() - record.startedAt,
|
|
512
|
+
parentTaskId: record.parentTaskId,
|
|
513
|
+
startedAt: record.startedAt,
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
return infos.sort((a, b) => a.startedAt - b.startedAt);
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Wait for a specific async sub-agent to complete and return its result.
|
|
521
|
+
*
|
|
522
|
+
* @param taskId The task ID to wait for
|
|
523
|
+
* @returns The sub-agent's result
|
|
524
|
+
* @throws Error if the task doesn't exist or was cancelled
|
|
525
|
+
*/
|
|
526
|
+
async waitFor(taskId) {
|
|
527
|
+
const record = this.records.get(taskId);
|
|
528
|
+
if (!record) {
|
|
529
|
+
throw new Error(`Sub-agent task "${taskId}" not found`);
|
|
530
|
+
}
|
|
531
|
+
// If already completed, return immediately
|
|
532
|
+
if (record.status === 'completed' && record.result) {
|
|
533
|
+
return record.result;
|
|
534
|
+
}
|
|
535
|
+
// If already failed/cancelled, throw
|
|
536
|
+
if ((record.status === 'failed' || record.status === 'cancelled') &&
|
|
537
|
+
record.result) {
|
|
538
|
+
if (record.result.error) {
|
|
539
|
+
throw new Error(record.result.error);
|
|
540
|
+
}
|
|
541
|
+
return record.result;
|
|
542
|
+
}
|
|
543
|
+
return record.completionPromise;
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Wait for all currently running async sub-agents to complete.
|
|
547
|
+
* Returns results for all tasks that were active at the time of the call.
|
|
548
|
+
*
|
|
549
|
+
* @returns Array of results from all awaited sub-agents
|
|
550
|
+
*/
|
|
551
|
+
async waitForAll() {
|
|
552
|
+
const activeIds = [];
|
|
553
|
+
for (const [id, record] of this.records) {
|
|
554
|
+
if (record.status === 'pending' ||
|
|
555
|
+
record.status === 'running') {
|
|
556
|
+
activeIds.push(id);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
const results = await Promise.allSettled(activeIds.map((id) => this.waitFor(id)));
|
|
560
|
+
return results.map((r, i) => {
|
|
561
|
+
if (r.status === 'fulfilled')
|
|
562
|
+
return r.value;
|
|
563
|
+
// Rejected — return a failure result
|
|
564
|
+
return {
|
|
565
|
+
success: false,
|
|
566
|
+
output: '',
|
|
567
|
+
filesModified: [],
|
|
568
|
+
tokensUsed: 0,
|
|
569
|
+
turnsCompleted: 0,
|
|
570
|
+
error: r.reason?.message ?? String(r.reason),
|
|
571
|
+
duration: 0,
|
|
572
|
+
};
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* Get the result of a completed sub-agent.
|
|
577
|
+
*
|
|
578
|
+
* @param taskId The task ID
|
|
579
|
+
* @returns The result, or undefined if not completed
|
|
580
|
+
*/
|
|
581
|
+
getResult(taskId) {
|
|
582
|
+
const record = this.records.get(taskId);
|
|
583
|
+
if (!record || record.status !== 'completed')
|
|
584
|
+
return undefined;
|
|
585
|
+
return record.result;
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Get the current nesting depth of this manager.
|
|
589
|
+
* Useful for checking whether further sub-agent spawning is allowed.
|
|
590
|
+
*/
|
|
591
|
+
get depth() {
|
|
592
|
+
return this.currentDepth;
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* Check whether this manager can spawn additional sub-agents
|
|
596
|
+
* (i.e., we haven't hit the max depth).
|
|
597
|
+
*/
|
|
598
|
+
get canSpawn() {
|
|
599
|
+
return this.currentDepth < this.maxGlobalDepth;
|
|
600
|
+
}
|
|
601
|
+
// ----------------------------------------------------------
|
|
602
|
+
// Private — Record Management
|
|
603
|
+
// ----------------------------------------------------------
|
|
604
|
+
/**
|
|
605
|
+
* Generate a unique task ID.
|
|
606
|
+
*/
|
|
607
|
+
generateTaskId(name) {
|
|
608
|
+
this.taskCounter++;
|
|
609
|
+
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 20);
|
|
610
|
+
return `sub-${slug}-${this.taskCounter}-${Date.now().toString(36)}`;
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Apply default values to a SubAgentConfig.
|
|
614
|
+
*/
|
|
615
|
+
applyDefaults(config) {
|
|
616
|
+
return {
|
|
617
|
+
name: config.name,
|
|
618
|
+
systemPrompt: config.systemPrompt,
|
|
619
|
+
allowedTools: config.allowedTools,
|
|
620
|
+
deniedTools: config.deniedTools ?? [],
|
|
621
|
+
fileScope: config.fileScope ?? {
|
|
622
|
+
allowedPaths: [this.workingDirectory],
|
|
623
|
+
deniedPaths: [],
|
|
624
|
+
},
|
|
625
|
+
resourceLimits: {
|
|
626
|
+
maxTokens: config.resourceLimits?.maxTokens ?? DEFAULT_RESOURCE_LIMITS.maxTokens,
|
|
627
|
+
maxTurns: config.resourceLimits?.maxTurns ?? DEFAULT_RESOURCE_LIMITS.maxTurns,
|
|
628
|
+
timeoutMs: config.resourceLimits?.timeoutMs ?? DEFAULT_RESOURCE_LIMITS.timeoutMs,
|
|
629
|
+
},
|
|
630
|
+
model: config.model ?? this.defaultModel,
|
|
631
|
+
maxDepth: config.maxDepth ?? DEFAULT_MAX_DEPTH,
|
|
632
|
+
mode: config.mode ?? 'sequential',
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Validate a SubAgentConfig, throwing if it's invalid.
|
|
637
|
+
*/
|
|
638
|
+
validateConfig(config) {
|
|
639
|
+
if (!config.name || config.name.trim().length === 0) {
|
|
640
|
+
throw new Error('Sub-agent config must have a non-empty name');
|
|
641
|
+
}
|
|
642
|
+
if (!config.systemPrompt || config.systemPrompt.trim().length === 0) {
|
|
643
|
+
throw new Error('Sub-agent config must have a non-empty systemPrompt');
|
|
644
|
+
}
|
|
645
|
+
if (!config.allowedTools || config.allowedTools.length === 0) {
|
|
646
|
+
throw new Error('Sub-agent config must specify at least one allowed tool');
|
|
647
|
+
}
|
|
648
|
+
if (config.resourceLimits.maxTokens <= 0) {
|
|
649
|
+
throw new Error('Sub-agent resourceLimits.maxTokens must be > 0');
|
|
650
|
+
}
|
|
651
|
+
if (config.resourceLimits.maxTurns <= 0) {
|
|
652
|
+
throw new Error('Sub-agent resourceLimits.maxTurns must be > 0');
|
|
653
|
+
}
|
|
654
|
+
if (config.resourceLimits.timeoutMs <= 0) {
|
|
655
|
+
throw new Error('Sub-agent resourceLimits.timeoutMs must be > 0');
|
|
656
|
+
}
|
|
657
|
+
// Check nesting depth
|
|
658
|
+
const nextDepth = this.currentDepth + 1;
|
|
659
|
+
if (nextDepth > this.maxGlobalDepth) {
|
|
660
|
+
throw new Error(`Cannot spawn sub-agent: nesting depth ${nextDepth} exceeds maximum ${this.maxGlobalDepth}`);
|
|
661
|
+
}
|
|
662
|
+
if (config.maxDepth > this.maxGlobalDepth) {
|
|
663
|
+
throw new Error(`Sub-agent maxDepth ${config.maxDepth} exceeds global maximum ${this.maxGlobalDepth}`);
|
|
664
|
+
}
|
|
665
|
+
// Validate that allowed tools exist in the parent registry
|
|
666
|
+
for (const toolName of config.allowedTools) {
|
|
667
|
+
if (!this.parentRegistry.has(toolName)) {
|
|
668
|
+
throw new Error(`Allowed tool "${toolName}" does not exist in the parent tool registry`);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* Create a SubAgentRecord for tracking the sub-agent lifecycle.
|
|
674
|
+
*/
|
|
675
|
+
createRecord(taskId, config, prompt) {
|
|
676
|
+
let completionResolve = null;
|
|
677
|
+
let completionReject = null;
|
|
678
|
+
const completionPromise = new Promise((resolve, reject) => {
|
|
679
|
+
completionResolve = resolve;
|
|
680
|
+
completionReject = reject;
|
|
681
|
+
});
|
|
682
|
+
const record = {
|
|
683
|
+
taskId,
|
|
684
|
+
config,
|
|
685
|
+
prompt,
|
|
686
|
+
status: 'pending',
|
|
687
|
+
depth: this.currentDepth + 1,
|
|
688
|
+
workingDirectory: this.workingDirectory,
|
|
689
|
+
sessionId: this.sessionId,
|
|
690
|
+
abortController: new AbortController(),
|
|
691
|
+
startedAt: Date.now(),
|
|
692
|
+
fileSnapshot: new Map(),
|
|
693
|
+
completionPromise,
|
|
694
|
+
completionResolve,
|
|
695
|
+
completionReject,
|
|
696
|
+
};
|
|
697
|
+
this.records.set(taskId, record);
|
|
698
|
+
return record;
|
|
699
|
+
}
|
|
700
|
+
// ----------------------------------------------------------
|
|
701
|
+
// Private — Execution Engine
|
|
702
|
+
// ----------------------------------------------------------
|
|
703
|
+
/**
|
|
704
|
+
* Execute a sub-agent: create a scoped BaseAgent, run it with
|
|
705
|
+
* resource limit enforcement, and collect results.
|
|
706
|
+
*/
|
|
707
|
+
async executeSubAgent(record, config, prompt) {
|
|
708
|
+
record.status = 'running';
|
|
709
|
+
this.emit('subagent:started', record.taskId, config.name);
|
|
710
|
+
const startTime = Date.now();
|
|
711
|
+
const abortSignal = record.abortController.signal;
|
|
712
|
+
// Take a file snapshot before execution
|
|
713
|
+
const snapshot = new FileSnapshot(this.workingDirectory, config.fileScope);
|
|
714
|
+
record.fileSnapshot = snapshot.take();
|
|
715
|
+
// Build the scoped tool registry
|
|
716
|
+
const scopedRegistry = new ScopedToolRegistry(this.parentRegistry, config.allowedTools, config.deniedTools ?? [], config.fileScope, this.workingDirectory);
|
|
717
|
+
// Build the AgentConfig for the sub-agent's BaseAgent
|
|
718
|
+
const agentConfig = {
|
|
719
|
+
name: config.name,
|
|
720
|
+
description: `Sub-agent: ${config.name}`,
|
|
721
|
+
systemPrompt: this.buildSubAgentSystemPrompt(config),
|
|
722
|
+
model: config.model,
|
|
723
|
+
maxTokens: config.resourceLimits.maxTokens,
|
|
724
|
+
maxIterations: config.resourceLimits.maxTurns,
|
|
725
|
+
tools: config.allowedTools.filter((t) => !(config.deniedTools ?? []).includes(t)),
|
|
726
|
+
autoApprove: true, // Sub-agents auto-approve; the parent already gated access
|
|
727
|
+
temperature: 0.5,
|
|
728
|
+
};
|
|
729
|
+
// Create the temporary BaseAgent
|
|
730
|
+
const agent = new BaseAgent(agentConfig, this.client, scopedRegistry, this.workingDirectory, `${this.sessionId}-sub-${record.taskId}`);
|
|
731
|
+
// Set up timeout
|
|
732
|
+
let timeoutHandle = null;
|
|
733
|
+
let timedOut = false;
|
|
734
|
+
if (config.resourceLimits.timeoutMs > 0) {
|
|
735
|
+
timeoutHandle = setTimeout(() => {
|
|
736
|
+
timedOut = true;
|
|
737
|
+
record.abortController.abort();
|
|
738
|
+
}, config.resourceLimits.timeoutMs);
|
|
739
|
+
}
|
|
740
|
+
// Resource-tracking state
|
|
741
|
+
let totalTokensUsed = 0;
|
|
742
|
+
let turnsCompleted = 0;
|
|
743
|
+
// Callbacks that enforce resource limits
|
|
744
|
+
const resourceCallbacks = {
|
|
745
|
+
onIteration: (iteration, _maxIterations) => {
|
|
746
|
+
turnsCompleted = iteration;
|
|
747
|
+
// Check for abort (timeout or explicit cancel)
|
|
748
|
+
if (abortSignal.aborted) {
|
|
749
|
+
throw new Error(timedOut
|
|
750
|
+
? `Sub-agent "${config.name}" timed out after ${config.resourceLimits.timeoutMs}ms`
|
|
751
|
+
: `Sub-agent "${config.name}" was cancelled`);
|
|
752
|
+
}
|
|
753
|
+
// Check token budget
|
|
754
|
+
if (totalTokensUsed >= config.resourceLimits.maxTokens) {
|
|
755
|
+
throw new Error(`Sub-agent "${config.name}" exceeded token budget of ${config.resourceLimits.maxTokens}`);
|
|
756
|
+
}
|
|
757
|
+
},
|
|
758
|
+
onToolCall: (_toolName, _args) => {
|
|
759
|
+
// Check for abort before each tool call
|
|
760
|
+
if (abortSignal.aborted) {
|
|
761
|
+
throw new Error(timedOut
|
|
762
|
+
? `Sub-agent "${config.name}" timed out`
|
|
763
|
+
: `Sub-agent "${config.name}" was cancelled`);
|
|
764
|
+
}
|
|
765
|
+
},
|
|
766
|
+
};
|
|
767
|
+
try {
|
|
768
|
+
// Run the sub-agent
|
|
769
|
+
const runResult = await agent.run(prompt, resourceCallbacks);
|
|
770
|
+
// Clear timeout
|
|
771
|
+
if (timeoutHandle !== null)
|
|
772
|
+
clearTimeout(timeoutHandle);
|
|
773
|
+
// Account for token usage
|
|
774
|
+
totalTokensUsed = runResult.usage.inputTokens + runResult.usage.outputTokens;
|
|
775
|
+
// Detect file changes
|
|
776
|
+
const fileChanges = snapshot.diff();
|
|
777
|
+
const filesModified = fileChanges.map((fc) => relative(this.workingDirectory, fc.path) || fc.path);
|
|
778
|
+
const result = {
|
|
779
|
+
success: true,
|
|
780
|
+
output: runResult.content || '',
|
|
781
|
+
filesModified,
|
|
782
|
+
tokensUsed: totalTokensUsed,
|
|
783
|
+
turnsCompleted: runResult.iterations,
|
|
784
|
+
duration: Date.now() - startTime,
|
|
785
|
+
};
|
|
786
|
+
record.status = 'completed';
|
|
787
|
+
record.result = result;
|
|
788
|
+
record.completedAt = Date.now();
|
|
789
|
+
record.completionResolve?.(result);
|
|
790
|
+
this.emit('subagent:completed', record.taskId, result);
|
|
791
|
+
return result;
|
|
792
|
+
}
|
|
793
|
+
catch (err) {
|
|
794
|
+
if (timeoutHandle !== null)
|
|
795
|
+
clearTimeout(timeoutHandle);
|
|
796
|
+
// If timed out, set the appropriate status
|
|
797
|
+
if (timedOut) {
|
|
798
|
+
record.status = 'timed_out';
|
|
799
|
+
}
|
|
800
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
801
|
+
// Detect file changes even on failure (partial modifications are important)
|
|
802
|
+
const fileChanges = snapshot.diff();
|
|
803
|
+
const filesModified = fileChanges.map((fc) => relative(this.workingDirectory, fc.path) || fc.path);
|
|
804
|
+
const result = {
|
|
805
|
+
success: false,
|
|
806
|
+
output: '',
|
|
807
|
+
filesModified,
|
|
808
|
+
tokensUsed: totalTokensUsed,
|
|
809
|
+
turnsCompleted,
|
|
810
|
+
error: error.message,
|
|
811
|
+
duration: Date.now() - startTime,
|
|
812
|
+
};
|
|
813
|
+
record.result = result;
|
|
814
|
+
record.completedAt = Date.now();
|
|
815
|
+
if (record.status === 'running') {
|
|
816
|
+
record.status = 'failed';
|
|
817
|
+
}
|
|
818
|
+
this.emit('subagent:failed', record.taskId, error.message);
|
|
819
|
+
// For sequential mode, re-throw so the caller knows it failed
|
|
820
|
+
if (config.mode === 'sequential') {
|
|
821
|
+
throw error;
|
|
822
|
+
}
|
|
823
|
+
// For async mode, the result is stored and accessible via getStatus/getResult
|
|
824
|
+
return result;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* Build the system prompt for a sub-agent, combining the user-provided
|
|
829
|
+
* system prompt with context about the sub-agent's restrictions.
|
|
830
|
+
*/
|
|
831
|
+
buildSubAgentSystemPrompt(config) {
|
|
832
|
+
let prompt = config.systemPrompt;
|
|
833
|
+
prompt += `\n\n## Sub-Agent Context`;
|
|
834
|
+
prompt += `\n- You are a sub-agent named "${config.name}"`;
|
|
835
|
+
prompt += `\n- Available tools: ${config.allowedTools.filter((t) => !(config.deniedTools ?? []).includes(t)).join(', ')}`;
|
|
836
|
+
if (config.fileScope) {
|
|
837
|
+
const allowedPaths = config.fileScope.allowedPaths.join(', ') || '(none)';
|
|
838
|
+
const deniedPaths = config.fileScope.deniedPaths.join(', ') || '(none)';
|
|
839
|
+
prompt += `\n- Allowed file paths: ${allowedPaths}`;
|
|
840
|
+
prompt += `\n- Denied file paths: ${deniedPaths}`;
|
|
841
|
+
prompt += `\n- IMPORTANT: You can only access files within the allowed paths. Attempts to access other paths will be denied.`;
|
|
842
|
+
}
|
|
843
|
+
prompt += `\n- Token budget: ${config.resourceLimits.maxTokens.toLocaleString()} tokens`;
|
|
844
|
+
prompt += `\n- Turn limit: ${config.resourceLimits.maxTurns} turns`;
|
|
845
|
+
prompt += `\n- Timeout: ${Math.round(config.resourceLimits.timeoutMs / 1000)} seconds`;
|
|
846
|
+
if (config.maxDepth > 0 && this.currentDepth + 1 < this.maxGlobalDepth) {
|
|
847
|
+
prompt += `\n- You may spawn sub-agents up to depth ${config.maxDepth}`;
|
|
848
|
+
}
|
|
849
|
+
else {
|
|
850
|
+
prompt += `\n- You cannot spawn further sub-agents (depth limit reached)`;
|
|
851
|
+
}
|
|
852
|
+
prompt += `\n\n## Sub-Agent Guidelines
|
|
853
|
+
- Work efficiently within your resource limits
|
|
854
|
+
- Focus on the specific task you were given
|
|
855
|
+
- Do not attempt to access files or tools outside your scope
|
|
856
|
+
- When you complete your task, provide a clear summary of what you did
|
|
857
|
+
- If you encounter scope violations, report them rather than retrying`;
|
|
858
|
+
return prompt;
|
|
859
|
+
}
|
|
860
|
+
// ----------------------------------------------------------
|
|
861
|
+
// Private — Utilities
|
|
862
|
+
// ----------------------------------------------------------
|
|
863
|
+
/**
|
|
864
|
+
* Clean up completed/failed/cancelled sub-agent records.
|
|
865
|
+
* Returns the number of records cleaned up.
|
|
866
|
+
*/
|
|
867
|
+
cleanup() {
|
|
868
|
+
let cleaned = 0;
|
|
869
|
+
for (const [id, record] of this.records) {
|
|
870
|
+
if (record.status === 'completed' ||
|
|
871
|
+
record.status === 'failed' ||
|
|
872
|
+
record.status === 'cancelled' ||
|
|
873
|
+
record.status === 'timed_out') {
|
|
874
|
+
this.records.delete(id);
|
|
875
|
+
cleaned++;
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
return cleaned;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
// ============================================================
|
|
882
|
+
// SubAgentTool — A tool that allows agents to spawn sub-agents
|
|
883
|
+
// ============================================================
|
|
884
|
+
/**
|
|
885
|
+
* A ToolExecutor that can be registered in the parent agent's tool
|
|
886
|
+
* registry to give the agent the ability to spawn sub-agents via
|
|
887
|
+
* the LLM tool-calling interface.
|
|
888
|
+
*
|
|
889
|
+
* This tool appears as "spawn_sub_agent" in the agent's tool list
|
|
890
|
+
* and accepts the sub-agent configuration + prompt as arguments.
|
|
891
|
+
*
|
|
892
|
+
* Usage:
|
|
893
|
+
* ```typescript
|
|
894
|
+
* const manager = new SubAgentManager(client, registry, cwd, sessionId);
|
|
895
|
+
* const subAgentTool = new SubAgentTool(manager);
|
|
896
|
+
* registry.register(subAgentTool);
|
|
897
|
+
* ```
|
|
898
|
+
*/
|
|
899
|
+
export class SubAgentTool {
|
|
900
|
+
name = 'spawn_sub_agent';
|
|
901
|
+
description = 'Spawn a sub-agent to perform a specific task. The sub-agent runs with restricted tools and resources. Use this to delegate focused work to a specialist agent.';
|
|
902
|
+
risk = 'medium';
|
|
903
|
+
definition = {
|
|
904
|
+
name: 'spawn_sub_agent',
|
|
905
|
+
description: 'Spawn a sub-agent to perform a specific task. The sub-agent runs with restricted tools and resources. Use this to delegate focused work to a specialist agent.',
|
|
906
|
+
parameters: {
|
|
907
|
+
type: 'object',
|
|
908
|
+
properties: {
|
|
909
|
+
name: {
|
|
910
|
+
type: 'string',
|
|
911
|
+
description: 'A descriptive name for the sub-agent (e.g., "code-searcher", "test-runner")',
|
|
912
|
+
},
|
|
913
|
+
prompt: {
|
|
914
|
+
type: 'string',
|
|
915
|
+
description: 'The task to assign to the sub-agent',
|
|
916
|
+
},
|
|
917
|
+
allowedTools: {
|
|
918
|
+
type: 'string',
|
|
919
|
+
description: 'Comma-separated list of tool names the sub-agent can use (e.g., "read_file,search_files,list_directory")',
|
|
920
|
+
},
|
|
921
|
+
maxTokens: {
|
|
922
|
+
type: 'number',
|
|
923
|
+
description: 'Maximum tokens the sub-agent may consume (default: 50000)',
|
|
924
|
+
},
|
|
925
|
+
maxTurns: {
|
|
926
|
+
type: 'number',
|
|
927
|
+
description: 'Maximum number of turns the sub-agent may take (default: 10)',
|
|
928
|
+
},
|
|
929
|
+
timeoutSeconds: {
|
|
930
|
+
type: 'number',
|
|
931
|
+
description: 'Timeout in seconds for the sub-agent (default: 60)',
|
|
932
|
+
},
|
|
933
|
+
},
|
|
934
|
+
required: ['name', 'prompt', 'allowedTools'],
|
|
935
|
+
},
|
|
936
|
+
};
|
|
937
|
+
manager;
|
|
938
|
+
constructor(manager) {
|
|
939
|
+
this.manager = manager;
|
|
940
|
+
}
|
|
941
|
+
async execute(args, _context) {
|
|
942
|
+
const name = args.name;
|
|
943
|
+
const prompt = args.prompt;
|
|
944
|
+
const allowedToolsStr = args.allowedTools;
|
|
945
|
+
const maxTokens = args.maxTokens ?? 50_000;
|
|
946
|
+
const maxTurns = args.maxTurns ?? 10;
|
|
947
|
+
const timeoutSeconds = args.timeoutSeconds ?? 60;
|
|
948
|
+
if (!name || !prompt || !allowedToolsStr) {
|
|
949
|
+
return 'ERROR: Missing required parameters: name, prompt, allowedTools';
|
|
950
|
+
}
|
|
951
|
+
const allowedTools = allowedToolsStr
|
|
952
|
+
.split(',')
|
|
953
|
+
.map((t) => t.trim())
|
|
954
|
+
.filter((t) => t.length > 0);
|
|
955
|
+
if (allowedTools.length === 0) {
|
|
956
|
+
return 'ERROR: At least one tool must be specified in allowedTools';
|
|
957
|
+
}
|
|
958
|
+
// Check if the manager can still spawn
|
|
959
|
+
if (!this.manager.canSpawn) {
|
|
960
|
+
return 'ERROR: Maximum sub-agent nesting depth reached. Cannot spawn further sub-agents.';
|
|
961
|
+
}
|
|
962
|
+
const config = {
|
|
963
|
+
name,
|
|
964
|
+
systemPrompt: `You are a specialized sub-agent named "${name}". Focus on completing the specific task you are assigned efficiently.`,
|
|
965
|
+
allowedTools,
|
|
966
|
+
resourceLimits: {
|
|
967
|
+
maxTokens,
|
|
968
|
+
maxTurns,
|
|
969
|
+
timeoutMs: timeoutSeconds * 1000,
|
|
970
|
+
},
|
|
971
|
+
maxDepth: DEFAULT_MAX_DEPTH,
|
|
972
|
+
mode: 'sequential',
|
|
973
|
+
};
|
|
974
|
+
try {
|
|
975
|
+
const result = await this.manager.spawn(config, prompt);
|
|
976
|
+
if (result.success) {
|
|
977
|
+
let output = `Sub-agent "${name}" completed successfully.\n`;
|
|
978
|
+
output += `Duration: ${Math.round(result.duration / 1000)}s | Tokens: ${result.tokensUsed.toLocaleString()} | Turns: ${result.turnsCompleted}\n`;
|
|
979
|
+
if (result.filesModified.length > 0) {
|
|
980
|
+
output += `Files modified: ${result.filesModified.join(', ')}\n`;
|
|
981
|
+
}
|
|
982
|
+
output += `\nResult:\n${result.output}`;
|
|
983
|
+
return output;
|
|
984
|
+
}
|
|
985
|
+
else {
|
|
986
|
+
return `Sub-agent "${name}" failed: ${result.error}\nTokens used: ${result.tokensUsed.toLocaleString()} | Turns: ${result.turnsCompleted}`;
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
catch (err) {
|
|
990
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
991
|
+
return `ERROR: Sub-agent "${name}" encountered an error: ${error}`;
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
getApprovalRequest(args) {
|
|
995
|
+
// Sub-agent spawning is medium risk — it could modify files
|
|
996
|
+
// but is already scoped by the config
|
|
997
|
+
return {
|
|
998
|
+
toolName: this.name,
|
|
999
|
+
args,
|
|
1000
|
+
risk: 'medium',
|
|
1001
|
+
description: `Spawn sub-agent "${args.name}" with tools: ${args.allowedTools}`,
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
// ============================================================
|
|
1006
|
+
// Helper — Create a SubAgentManager from a NeuroEngine context
|
|
1007
|
+
// ============================================================
|
|
1008
|
+
/**
|
|
1009
|
+
* Convenience factory for creating a SubAgentManager from the
|
|
1010
|
+
* standard NeuroEngine components.
|
|
1011
|
+
*
|
|
1012
|
+
* @param client The OpenRouter client from the engine
|
|
1013
|
+
* @param registry The tool registry from the engine
|
|
1014
|
+
* @param workingDir The current working directory
|
|
1015
|
+
* @param sessionId The current session ID
|
|
1016
|
+
* @param defaultModel The model to use for sub-agents
|
|
1017
|
+
* @param parentDepth The current nesting depth (0 for top-level)
|
|
1018
|
+
* @returns A configured SubAgentManager
|
|
1019
|
+
*/
|
|
1020
|
+
export function createSubAgentManager(client, registry, workingDir, sessionId, defaultModel = 'qwen/qwen3-coder:free', parentDepth = 0) {
|
|
1021
|
+
return new SubAgentManager(client, registry, workingDir, sessionId, defaultModel, parentDepth, DEFAULT_MAX_DEPTH);
|
|
1022
|
+
}
|
|
1023
|
+
//# sourceMappingURL=sub-agent.js.map
|