agent-pool-mcp 1.4.0 → 1.6.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/package.json +1 -1
- package/skills/group-lead.md +30 -0
- package/src/scheduler/daemon.js +158 -57
- package/src/scheduler/pipeline.js +21 -3
- package/src/server.js +116 -1
- package/src/tool-definitions.js +48 -0
- package/src/tools/groups.js +124 -0
package/package.json
CHANGED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: group-lead
|
|
3
|
+
description: Fractal group leader — breaks down tasks and delegates to group members via delegate_to_group.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Group Lead
|
|
7
|
+
|
|
8
|
+
You are a group leader in a fractal agent orchestration system. Your role is to:
|
|
9
|
+
|
|
10
|
+
1. **Analyze** the incoming task and break it into independent subtasks
|
|
11
|
+
2. **Create groups** if they don't exist yet, with appropriate skills and policies
|
|
12
|
+
3. **Delegate** subtasks to group members using `delegate_to_group`
|
|
13
|
+
4. **Collect** results from all agents using `get_task_result`
|
|
14
|
+
5. **Synthesize** a final report combining all results
|
|
15
|
+
|
|
16
|
+
## Rules
|
|
17
|
+
|
|
18
|
+
- Break tasks into pieces that can run in parallel
|
|
19
|
+
- Each subtask should be self-contained (agent can complete it without external context)
|
|
20
|
+
- Set appropriate policies: use `read-only` for analysis, `safe-edit` for code changes
|
|
21
|
+
- Always set `max_agents` to prevent runaway spawning
|
|
22
|
+
- Collect ALL results before synthesizing — don't skip slow agents
|
|
23
|
+
- Report both successes and failures
|
|
24
|
+
|
|
25
|
+
## Output Format
|
|
26
|
+
|
|
27
|
+
After collecting all results, produce a structured summary:
|
|
28
|
+
1. Task overview (what was asked)
|
|
29
|
+
2. Subtask results (one per agent)
|
|
30
|
+
3. Synthesized conclusion
|
package/src/scheduler/daemon.js
CHANGED
|
@@ -11,10 +11,14 @@
|
|
|
11
11
|
* @module agent-pool/scheduler/daemon
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from 'node:fs';
|
|
14
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, readdirSync } from 'node:fs';
|
|
15
15
|
import { spawn } from 'node:child_process';
|
|
16
16
|
import { join, dirname } from 'node:path';
|
|
17
17
|
import { matchesCron } from './cron.js';
|
|
18
|
+
import { getGroup } from '../tools/groups.js';
|
|
19
|
+
import { getRunner } from '../runner/config.js';
|
|
20
|
+
import { buildSshSpawn } from '../runner/ssh.js';
|
|
21
|
+
import { killGroup } from '../runner/process-manager.js';
|
|
18
22
|
|
|
19
23
|
const POLL_INTERVAL_MS = 30_000; // Check schedules every 30 seconds
|
|
20
24
|
const PID_FILE = '.agents/scheduler.pid';
|
|
@@ -161,60 +165,119 @@ function executeSchedule(schedule) {
|
|
|
161
165
|
|
|
162
166
|
// ─── Pipeline tick ──────────────────────────────────────────
|
|
163
167
|
|
|
164
|
-
import { readdirSync } from 'node:fs';
|
|
165
|
-
|
|
166
168
|
const PIPELINES_DIR = '.agents/pipelines';
|
|
167
169
|
const RUNS_DIR = '.agents/runs';
|
|
168
170
|
|
|
169
171
|
/**
|
|
170
|
-
* Spawn
|
|
172
|
+
* Spawn Gemini CLI agent(s) for a pipeline step.
|
|
171
173
|
* @param {object} stepDef - Step definition from pipeline
|
|
172
174
|
* @param {object} run - Current run state
|
|
173
175
|
* @param {string} runId
|
|
174
176
|
* @param {string} [bounceReason] - If bouncing back, the reason
|
|
175
|
-
* @returns {number} child
|
|
177
|
+
* @returns {number[]} Array of child PIDs
|
|
176
178
|
*/
|
|
177
179
|
function spawnStep(stepDef, run, runId, bounceReason) {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
180
|
+
const count = stepDef.count || 1;
|
|
181
|
+
const pids = [];
|
|
182
|
+
|
|
183
|
+
// Resolve group
|
|
184
|
+
let groupConfig = {};
|
|
185
|
+
if (stepDef.group) {
|
|
186
|
+
groupConfig = getGroup(run.cwd || cwd, stepDef.group) || {};
|
|
181
187
|
}
|
|
182
188
|
|
|
183
|
-
|
|
184
|
-
|
|
189
|
+
const skill = stepDef.skill || groupConfig.skill;
|
|
190
|
+
const policy = groupConfig.policy; // currently policy only from group
|
|
191
|
+
const runnerId = groupConfig.runner;
|
|
192
|
+
const runner = runnerId ? getRunner(runnerId) : { type: 'local' };
|
|
193
|
+
const isRemote = runner && runner.type === 'ssh';
|
|
185
194
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
195
|
+
for (let i = 0; i < count; i++) {
|
|
196
|
+
let prompt = stepDef.prompt;
|
|
197
|
+
if (bounceReason) {
|
|
198
|
+
prompt = `${stepDef.prompt}\n\n⚠️ BOUNCE BACK: предыдущая попытка была отклонена следующим шагом.\nПричина: ${bounceReason}\nДополни и улучши результат.`;
|
|
199
|
+
}
|
|
191
200
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
196
|
-
detached: true,
|
|
197
|
-
});
|
|
201
|
+
if (count > 1) {
|
|
202
|
+
prompt = `[Agent ${i + 1}/${count}]\n\n${prompt}`;
|
|
203
|
+
}
|
|
198
204
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
+
// Inject pipeline context
|
|
206
|
+
prompt = `[Pipeline: ${run.pipelineName}, Step: ${stepDef.name}, Run: ${runId}]\n\nTask:\n${prompt}\n\nWhen finished, call signal_step_complete with step_name "${stepDef.name}" and run_id "${runId}".`;
|
|
207
|
+
|
|
208
|
+
const args = [
|
|
209
|
+
'-p', prompt,
|
|
210
|
+
'--output-format', 'stream-json',
|
|
211
|
+
'--approval-mode', stepDef.approvalMode || 'yolo',
|
|
212
|
+
];
|
|
213
|
+
|
|
214
|
+
if (skill) {
|
|
215
|
+
// Skills can be active via prompt injection, as we do for scheduled tasks
|
|
216
|
+
args[1] = `Activate skill "${skill}" first.\n\n${args[1]}`;
|
|
217
|
+
}
|
|
218
|
+
if (policy) {
|
|
219
|
+
args.push('--policy', policy);
|
|
220
|
+
}
|
|
221
|
+
if (groupConfig.include_dirs?.length > 0) {
|
|
222
|
+
for (const dir of groupConfig.include_dirs) {
|
|
223
|
+
args.push('--include-directories', dir);
|
|
205
224
|
}
|
|
206
|
-
|
|
207
|
-
} catch { /* ignore */ }
|
|
208
|
-
console.error(`[pipeline] Step "${stepDef.name}" exited (code: ${code}, run: ${runId})`);
|
|
209
|
-
});
|
|
225
|
+
}
|
|
210
226
|
|
|
211
|
-
|
|
212
|
-
|
|
227
|
+
let spawnCmd, spawnArgs, spawnOpts;
|
|
228
|
+
if (isRemote) {
|
|
229
|
+
const ssh = buildSshSpawn(runner, args, run.cwd || cwd);
|
|
230
|
+
spawnCmd = ssh.command;
|
|
231
|
+
spawnArgs = ssh.args;
|
|
232
|
+
spawnOpts = { stdio: ['pipe', 'pipe', 'pipe'], detached: true };
|
|
233
|
+
} else {
|
|
234
|
+
spawnCmd = 'gemini';
|
|
235
|
+
spawnArgs = args;
|
|
236
|
+
const currentDepth = parseInt(process.env.AGENT_POOL_DEPTH ?? '0');
|
|
237
|
+
spawnOpts = {
|
|
238
|
+
cwd: run.cwd || cwd,
|
|
239
|
+
env: {
|
|
240
|
+
...process.env,
|
|
241
|
+
TERM: 'dumb',
|
|
242
|
+
CI: '1',
|
|
243
|
+
AGENT_POOL_DEPTH: String(currentDepth + 1)
|
|
244
|
+
},
|
|
245
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
246
|
+
detached: true,
|
|
247
|
+
};
|
|
248
|
+
if (count > 1) spawnOpts.env.AGENT_INDEX = String(i);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const child = spawn(spawnCmd, spawnArgs, spawnOpts);
|
|
252
|
+
|
|
253
|
+
child.on('close', (code) => {
|
|
254
|
+
// Update step exit code in run state
|
|
255
|
+
try {
|
|
256
|
+
const currentRun = JSON.parse(readFileSync(join(cwd, RUNS_DIR, `${runId}.json`), 'utf-8'));
|
|
257
|
+
if (currentRun.steps[stepDef.name]) {
|
|
258
|
+
// If any child fails, set exit code to non-zero
|
|
259
|
+
if (code !== 0) {
|
|
260
|
+
currentRun.steps[stepDef.name].exitCode = code;
|
|
261
|
+
} else if (currentRun.steps[stepDef.name].exitCode === null) {
|
|
262
|
+
currentRun.steps[stepDef.name].exitCode = 0;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
writeFileSync(join(cwd, RUNS_DIR, `${runId}.json`), JSON.stringify(currentRun, null, 2));
|
|
266
|
+
} catch { /* ignore */ }
|
|
267
|
+
console.error(`[pipeline] Step "${stepDef.name}" [pid ${child.pid}] exited (code: ${code}, run: ${runId})`);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
child.stdin.end();
|
|
271
|
+
child.unref();
|
|
213
272
|
|
|
214
|
-
|
|
215
|
-
|
|
273
|
+
console.error(`[pipeline] Started step "${stepDef.name}" → pid ${child.pid} (run: ${runId})`);
|
|
274
|
+
pids.push(child.pid);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return pids;
|
|
216
278
|
}
|
|
217
279
|
|
|
280
|
+
|
|
218
281
|
/**
|
|
219
282
|
* Check if a process is alive.
|
|
220
283
|
* @param {number} pid
|
|
@@ -261,33 +324,67 @@ function tickPipelines() {
|
|
|
261
324
|
if (step.status === 'bounce_pending') {
|
|
262
325
|
step.status = 'running';
|
|
263
326
|
step.startedAt = new Date().toISOString();
|
|
264
|
-
|
|
327
|
+
const pids = spawnStep(stepDef, run, runId, step.lastBounceReason);
|
|
328
|
+
step.pids = pids;
|
|
329
|
+
if (pids.length > 0) step.pid = pids[0];
|
|
265
330
|
modified = true;
|
|
266
331
|
continue;
|
|
267
332
|
}
|
|
268
333
|
|
|
269
334
|
// ── Handle running steps: check if process died ──
|
|
270
|
-
if (step.status === 'running'
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
335
|
+
if (step.status === 'running') {
|
|
336
|
+
const pids = step.pids?.length > 0 ? step.pids : (step.pid ? [step.pid] : []);
|
|
337
|
+
if (pids.length === 0) continue;
|
|
338
|
+
|
|
339
|
+
let livingPids = 0;
|
|
340
|
+
for (const pid of pids) if (isAlive(pid)) livingPids++;
|
|
341
|
+
|
|
342
|
+
const isParallel = pids.length > 1;
|
|
343
|
+
|
|
344
|
+
if (isParallel) {
|
|
345
|
+
// Parallel semantics: rely entirely on exit codes
|
|
346
|
+
if (step.exitCode !== null && step.exitCode !== 0) {
|
|
347
|
+
// Fail fast: kill siblings
|
|
348
|
+
for (const pid of pids) if (isAlive(pid)) killGroup(pid);
|
|
349
|
+
step.status = 'failed';
|
|
350
|
+
step.completedAt = new Date().toISOString();
|
|
351
|
+
console.error(`[pipeline] Step "${stepDef.name}" parallel failed (exit: ${step.exitCode})`);
|
|
352
|
+
if (pipeline.onError === 'stop') {
|
|
353
|
+
run.status = 'failed';
|
|
354
|
+
run.completedAt = new Date().toISOString();
|
|
289
355
|
}
|
|
290
356
|
modified = true;
|
|
357
|
+
} else if (livingPids === 0) {
|
|
358
|
+
// All dead and no errors
|
|
359
|
+
step.status = 'success';
|
|
360
|
+
step.completedAt = new Date().toISOString();
|
|
361
|
+
console.error(`[pipeline] Step "${stepDef.name}" parallel completed successfully`);
|
|
362
|
+
modified = true;
|
|
363
|
+
}
|
|
364
|
+
} else {
|
|
365
|
+
// Sequential semantics (count 1)
|
|
366
|
+
const pid = pids[0];
|
|
367
|
+
if (!isAlive(pid)) {
|
|
368
|
+
// Process is dead — did agent signal?
|
|
369
|
+
if (!step.signaled) {
|
|
370
|
+
// Auto-fallback: check exit code
|
|
371
|
+
if (step.exitCode === 0 || step.exitCode === null) {
|
|
372
|
+
// Treat as success (agent forgot to signal)
|
|
373
|
+
step.status = 'success';
|
|
374
|
+
step.completedAt = new Date().toISOString();
|
|
375
|
+
console.error(`[pipeline] Step "${stepDef.name}" auto-completed (pid dead, exit: ${step.exitCode})`);
|
|
376
|
+
} else {
|
|
377
|
+
// Failed
|
|
378
|
+
step.status = 'failed';
|
|
379
|
+
step.completedAt = new Date().toISOString();
|
|
380
|
+
console.error(`[pipeline] Step "${stepDef.name}" failed (exit: ${step.exitCode})`);
|
|
381
|
+
if (pipeline.onError === 'stop') {
|
|
382
|
+
run.status = 'failed';
|
|
383
|
+
run.completedAt = new Date().toISOString();
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
modified = true;
|
|
387
|
+
}
|
|
291
388
|
}
|
|
292
389
|
}
|
|
293
390
|
continue;
|
|
@@ -324,7 +421,9 @@ function tickPipelines() {
|
|
|
324
421
|
if (shouldStart && run.status === 'running') {
|
|
325
422
|
step.status = 'running';
|
|
326
423
|
step.startedAt = new Date().toISOString();
|
|
327
|
-
|
|
424
|
+
const pids = spawnStep(stepDef, run, runId);
|
|
425
|
+
step.pids = pids;
|
|
426
|
+
if (pids.length > 0) step.pid = pids[0];
|
|
328
427
|
modified = true;
|
|
329
428
|
}
|
|
330
429
|
}
|
|
@@ -335,7 +434,9 @@ function tickPipelines() {
|
|
|
335
434
|
if (depStepName && run.steps[depStepName]?.status === 'success') {
|
|
336
435
|
step.status = 'running';
|
|
337
436
|
step.startedAt = new Date().toISOString();
|
|
338
|
-
|
|
437
|
+
const pids = spawnStep(stepDef, run, runId);
|
|
438
|
+
step.pids = pids;
|
|
439
|
+
if (pids.length > 0) step.pid = pids[0];
|
|
339
440
|
modified = true;
|
|
340
441
|
}
|
|
341
442
|
}
|
|
@@ -11,6 +11,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, unlink
|
|
|
11
11
|
import { join, dirname } from 'node:path';
|
|
12
12
|
import { randomUUID } from 'node:crypto';
|
|
13
13
|
import { ensureDaemon } from './scheduler.js';
|
|
14
|
+
import { killGroup } from '../runner/process-manager.js';
|
|
14
15
|
|
|
15
16
|
const PIPELINES_DIR = '.agents/pipelines';
|
|
16
17
|
const RUNS_DIR = '.agents/runs';
|
|
@@ -69,6 +70,8 @@ export function createPipeline(cwd, { name, steps, onError }) {
|
|
|
69
70
|
name: s.name,
|
|
70
71
|
prompt: s.prompt,
|
|
71
72
|
skill: s.skill || null,
|
|
73
|
+
group: s.group || null,
|
|
74
|
+
count: s.count ? parseInt(s.count, 10) : 1,
|
|
72
75
|
approvalMode: s.approval_mode || 'yolo',
|
|
73
76
|
timeout: s.timeout || 600,
|
|
74
77
|
maxBounces: s.maxBounces ?? s.max_bounces ?? 2,
|
|
@@ -134,7 +137,8 @@ export function runPipeline(cwd, pipelineId) {
|
|
|
134
137
|
for (const step of pipeline.steps) {
|
|
135
138
|
steps[step.name] = {
|
|
136
139
|
status: 'pending',
|
|
137
|
-
pid: null,
|
|
140
|
+
pid: null, // Legacy / single pid
|
|
141
|
+
pids: [], // Array for parallel execution
|
|
138
142
|
exitCode: null,
|
|
139
143
|
signaled: false,
|
|
140
144
|
bounces: 0,
|
|
@@ -219,8 +223,13 @@ export function cancelRun(cwd, runId) {
|
|
|
219
223
|
|
|
220
224
|
// Kill any running step
|
|
221
225
|
for (const [name, step] of Object.entries(run.steps)) {
|
|
222
|
-
if (step.status === 'running'
|
|
223
|
-
|
|
226
|
+
if (step.status === 'running') {
|
|
227
|
+
const pidsToKill = [...(step.pids || [])];
|
|
228
|
+
if (step.pid && !pidsToKill.includes(step.pid)) pidsToKill.push(step.pid);
|
|
229
|
+
|
|
230
|
+
for (const pid of pidsToKill) {
|
|
231
|
+
killGroup(pid);
|
|
232
|
+
}
|
|
224
233
|
step.status = 'cancelled';
|
|
225
234
|
}
|
|
226
235
|
if (step.status === 'pending') {
|
|
@@ -332,7 +341,16 @@ export function bounceBack(cwd, targetStepName, reason, runId) {
|
|
|
332
341
|
targetStep.status = 'bounce_pending';
|
|
333
342
|
targetStep.bounces += 1;
|
|
334
343
|
targetStep.lastBounceReason = reason;
|
|
344
|
+
|
|
345
|
+
// Fail-fast: kill any remaining running agents for this step
|
|
346
|
+
const pidsToKill = [...(targetStep.pids || [])];
|
|
347
|
+
if (targetStep.pid && !pidsToKill.includes(targetStep.pid)) pidsToKill.push(targetStep.pid);
|
|
348
|
+
for (const pid of pidsToKill) {
|
|
349
|
+
killGroup(pid);
|
|
350
|
+
}
|
|
351
|
+
|
|
335
352
|
targetStep.pid = null;
|
|
353
|
+
targetStep.pids = [];
|
|
336
354
|
targetStep.exitCode = null;
|
|
337
355
|
targetStep.signaled = false;
|
|
338
356
|
|
package/src/server.js
CHANGED
|
@@ -22,6 +22,7 @@ import { listSkills, createSkill, deleteSkill, installSkill, provisionSkill } fr
|
|
|
22
22
|
import { consultPeer } from './tools/consult.js';
|
|
23
23
|
import { addSchedule, listSchedules, removeSchedule, getScheduledResults, getDaemonStatus } from './scheduler/scheduler.js';
|
|
24
24
|
import { createPipeline, listPipelines, runPipeline, getRun, listRuns, cancelRun, signalStepComplete, bounceBack } from './scheduler/pipeline.js';
|
|
25
|
+
import { createGroup, listGroups, getGroup } from './tools/groups.js';
|
|
25
26
|
|
|
26
27
|
import { TOOL_DEFINITIONS } from './tool-definitions.js';
|
|
27
28
|
|
|
@@ -68,6 +69,7 @@ Docs: https://github.com/google-gemini/gemini-cli`
|
|
|
68
69
|
/** Tools that require Gemini CLI to be installed */
|
|
69
70
|
const GEMINI_TOOLS = new Set([
|
|
70
71
|
'delegate_task', 'delegate_task_readonly', 'consult_peer', 'list_sessions',
|
|
72
|
+
'delegate_to_group',
|
|
71
73
|
]);
|
|
72
74
|
|
|
73
75
|
// ─── Depth tracking (for nested orchestration) ──────────────
|
|
@@ -110,7 +112,7 @@ export function createServer() {
|
|
|
110
112
|
}
|
|
111
113
|
|
|
112
114
|
const server = new Server(
|
|
113
|
-
{ name: 'agent-pool', version: '1.
|
|
115
|
+
{ name: 'agent-pool', version: '1.6.0' },
|
|
114
116
|
{ capabilities: { tools: {}, resources: {} } },
|
|
115
117
|
);
|
|
116
118
|
|
|
@@ -200,6 +202,12 @@ export function createServer() {
|
|
|
200
202
|
response = handleBounceBack(args); break;
|
|
201
203
|
case 'get_usage_guide':
|
|
202
204
|
response = handleGetUsageGuide(args); break;
|
|
205
|
+
case 'create_group':
|
|
206
|
+
response = handleCreateGroup(args); break;
|
|
207
|
+
case 'list_groups':
|
|
208
|
+
response = handleListGroups(args); break;
|
|
209
|
+
case 'delegate_to_group':
|
|
210
|
+
response = handleDelegateToGroup(args); break;
|
|
203
211
|
default:
|
|
204
212
|
response = { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true };
|
|
205
213
|
}
|
|
@@ -688,3 +696,110 @@ function handleGetUsageGuide(args) {
|
|
|
688
696
|
return { content: [{ type: 'text', text: topicContent.join('\n').trim() }] };
|
|
689
697
|
}
|
|
690
698
|
|
|
699
|
+
// ─── Group Handlers ─────────────────────────────────────────────
|
|
700
|
+
|
|
701
|
+
/** @param {object} args */
|
|
702
|
+
function handleCreateGroup(args) {
|
|
703
|
+
const cwd = args.cwd ?? defaultCwd;
|
|
704
|
+
const result = createGroup(cwd, {
|
|
705
|
+
name: args.name,
|
|
706
|
+
runner: args.runner,
|
|
707
|
+
skill: args.skill,
|
|
708
|
+
policy: args.policy,
|
|
709
|
+
max_agents: args.max_agents,
|
|
710
|
+
include_dirs: args.include_dirs,
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
const configParts = [];
|
|
714
|
+
if (args.runner) configParts.push(`runner: ${args.runner}`);
|
|
715
|
+
if (args.skill) configParts.push(`skill: ${args.skill}`);
|
|
716
|
+
if (args.policy) configParts.push(`policy: ${args.policy}`);
|
|
717
|
+
if (args.max_agents) configParts.push(`max: ${args.max_agents}`);
|
|
718
|
+
|
|
719
|
+
return {
|
|
720
|
+
content: [{
|
|
721
|
+
type: 'text',
|
|
722
|
+
text: `✅ Group ${result.created ? 'created' : 'updated'}: \`${args.name}\`${configParts.length > 0 ? `\n- ${configParts.join('\n- ')}` : ''}\n\nUse \`delegate_to_group\` to send tasks to this group.`,
|
|
723
|
+
}],
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/** @param {object} args */
|
|
728
|
+
function handleListGroups(args) {
|
|
729
|
+
const cwd = args.cwd ?? defaultCwd;
|
|
730
|
+
const groups = listGroups(cwd);
|
|
731
|
+
|
|
732
|
+
if (groups.length === 0) {
|
|
733
|
+
return { content: [{ type: 'text', text: 'No groups defined. Use `create_group` to create one.' }] };
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
const lines = groups.map((g) => {
|
|
737
|
+
const parts = [];
|
|
738
|
+
if (g.runner) parts.push(`runner: ${g.runner}`);
|
|
739
|
+
if (g.skill) parts.push(`skill: ${g.skill}`);
|
|
740
|
+
if (g.policy) parts.push(`policy: ${g.policy}`);
|
|
741
|
+
if (g.max_agents) parts.push(`max: ${g.max_agents}`);
|
|
742
|
+
return `- **${g.name}** — ${parts.join(', ') || 'no config'}`;
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
return {
|
|
746
|
+
content: [{ type: 'text', text: `## Agent Groups (${groups.length})\n\n${lines.join('\n')}` }],
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/** @param {object} args */
|
|
751
|
+
function handleDelegateToGroup(args) {
|
|
752
|
+
const cwd = args.cwd ?? defaultCwd;
|
|
753
|
+
const group = getGroup(cwd, args.group);
|
|
754
|
+
|
|
755
|
+
if (!group) {
|
|
756
|
+
return {
|
|
757
|
+
content: [{ type: 'text', text: `❌ Group \`${args.group}\` not found. Use \`list_groups\` to see available groups.` }],
|
|
758
|
+
isError: true,
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
const count = args.count ?? 1;
|
|
763
|
+
|
|
764
|
+
// Check max_agents limit
|
|
765
|
+
if (group.max_agents && count > group.max_agents) {
|
|
766
|
+
return {
|
|
767
|
+
content: [{
|
|
768
|
+
type: 'text',
|
|
769
|
+
text: `❌ Requested ${count} agents but group \`${args.group}\` allows max ${group.max_agents}.`,
|
|
770
|
+
}],
|
|
771
|
+
isError: true,
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
const taskIds = [];
|
|
776
|
+
|
|
777
|
+
for (let i = 0; i < count; i++) {
|
|
778
|
+
const delegateArgs = {
|
|
779
|
+
prompt: count > 1 ? `[Agent ${i + 1}/${count} in group "${args.group}"]\n\n${args.prompt}` : args.prompt,
|
|
780
|
+
cwd,
|
|
781
|
+
runner: group.runner || undefined,
|
|
782
|
+
skill: group.skill || undefined,
|
|
783
|
+
policy: group.policy || undefined,
|
|
784
|
+
include_dirs: group.include_dirs || undefined,
|
|
785
|
+
timeout: args.timeout,
|
|
786
|
+
};
|
|
787
|
+
|
|
788
|
+
const result = handleDelegate(delegateArgs, {
|
|
789
|
+
approvalMode: DEFAULT_APPROVAL_MODE,
|
|
790
|
+
emoji: '👥',
|
|
791
|
+
label: `Group task (${args.group} #${i + 1})`,
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
// Extract task_id from response text
|
|
795
|
+
const match = result.content[0].text.match(/`([0-9a-f-]{36})`/);
|
|
796
|
+
if (match) taskIds.push(match[1]);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
return {
|
|
800
|
+
content: [{
|
|
801
|
+
type: 'text',
|
|
802
|
+
text: `👥 Delegated to group \`${args.group}\` — ${count} agent(s) spawned.\n\n${taskIds.map((id, i) => `- Agent ${i + 1}: \`${id}\``).join('\n')}\n\nUse \`get_task_result\` to check each agent's status.`,
|
|
803
|
+
}],
|
|
804
|
+
};
|
|
805
|
+
}
|
package/src/tool-definitions.js
CHANGED
|
@@ -360,5 +360,53 @@ export const TOOL_DEFINITIONS = [
|
|
|
360
360
|
required: ['step_name', 'reason'],
|
|
361
361
|
},
|
|
362
362
|
},
|
|
363
|
+
// ─── Group Tools ────────────────────────────────────────
|
|
364
|
+
{
|
|
365
|
+
name: 'create_group',
|
|
366
|
+
description: 'Create a named agent group with shared config (runner, skill, policy). Groups are reusable presets for fractal orchestration.',
|
|
367
|
+
inputSchema: {
|
|
368
|
+
type: 'object',
|
|
369
|
+
properties: {
|
|
370
|
+
name: { type: 'string', description: 'Group name (e.g. "backend-team", "qa-group").' },
|
|
371
|
+
runner: { type: 'string', description: 'Default runner for agents in this group.' },
|
|
372
|
+
skill: { type: 'string', description: 'Default skill activated for all agents in this group.' },
|
|
373
|
+
policy: { type: 'string', description: 'Default policy restricting agent permissions.' },
|
|
374
|
+
max_agents: { type: 'number', description: 'Max concurrent agents allowed in this group.' },
|
|
375
|
+
include_dirs: { type: 'array', items: { type: 'string' }, description: 'Additional directories agents in this group can access.' },
|
|
376
|
+
cwd: { type: 'string', description: 'Project directory. Defaults to current working directory.' },
|
|
377
|
+
},
|
|
378
|
+
required: ['name'],
|
|
379
|
+
},
|
|
380
|
+
},
|
|
381
|
+
{
|
|
382
|
+
name: 'list_groups',
|
|
383
|
+
description: 'List all registered agent groups with their config.',
|
|
384
|
+
inputSchema: {
|
|
385
|
+
type: 'object',
|
|
386
|
+
properties: {
|
|
387
|
+
cwd: { type: 'string', description: 'Project directory. Defaults to current working directory.' },
|
|
388
|
+
},
|
|
389
|
+
},
|
|
390
|
+
},
|
|
391
|
+
{
|
|
392
|
+
name: 'delegate_to_group',
|
|
393
|
+
description: [
|
|
394
|
+
'Delegate a task to a named agent group. Spawns agents with the group\'s shared config (runner, skill, policy).',
|
|
395
|
+
'Use `count` to launch multiple agents in parallel (fractal orchestration).',
|
|
396
|
+
'',
|
|
397
|
+
'Returns array of task_ids immediately (non-blocking). Use get_task_result to check each.',
|
|
398
|
+
].join('\n'),
|
|
399
|
+
inputSchema: {
|
|
400
|
+
type: 'object',
|
|
401
|
+
properties: {
|
|
402
|
+
group: { type: 'string', description: 'Group name to delegate to.' },
|
|
403
|
+
prompt: { type: 'string', description: 'Task description for the agents.' },
|
|
404
|
+
count: { type: 'number', description: 'Number of agents to spawn. Default: 1.' },
|
|
405
|
+
cwd: { type: 'string', description: 'Working directory. Defaults to current working directory.' },
|
|
406
|
+
timeout: { type: 'number', description: 'Timeout in seconds. Overrides group default.' },
|
|
407
|
+
},
|
|
408
|
+
required: ['group', 'prompt'],
|
|
409
|
+
},
|
|
410
|
+
},
|
|
363
411
|
];
|
|
364
412
|
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Groups — named config presets for fractal orchestration.
|
|
3
|
+
* Groups bundle runner, skill, policy, and max_agents into a reusable team config.
|
|
4
|
+
*
|
|
5
|
+
* @module agent-pool/tools/groups
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
const GROUPS_DIR = '.agents';
|
|
12
|
+
const GROUPS_FILE = 'groups.json';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Get path to groups.json for a project.
|
|
16
|
+
*
|
|
17
|
+
* @param {string} cwd - Project directory
|
|
18
|
+
* @returns {string}
|
|
19
|
+
*/
|
|
20
|
+
function getGroupsPath(cwd) {
|
|
21
|
+
return path.join(cwd, GROUPS_DIR, GROUPS_FILE);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Load all groups from disk.
|
|
26
|
+
*
|
|
27
|
+
* @param {string} cwd - Project directory
|
|
28
|
+
* @returns {Object<string, object>}
|
|
29
|
+
*/
|
|
30
|
+
function loadGroups(cwd) {
|
|
31
|
+
const filePath = getGroupsPath(cwd);
|
|
32
|
+
if (!fs.existsSync(filePath)) return {};
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
35
|
+
} catch {
|
|
36
|
+
return {};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Save groups to disk.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} cwd - Project directory
|
|
44
|
+
* @param {Object<string, object>} groups
|
|
45
|
+
*/
|
|
46
|
+
function saveGroups(cwd, groups) {
|
|
47
|
+
const dirPath = path.join(cwd, GROUPS_DIR);
|
|
48
|
+
if (!fs.existsSync(dirPath)) {
|
|
49
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
50
|
+
}
|
|
51
|
+
fs.writeFileSync(getGroupsPath(cwd), JSON.stringify(groups, null, 2));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Create or update a group.
|
|
56
|
+
*
|
|
57
|
+
* @param {string} cwd - Project directory
|
|
58
|
+
* @param {object} config - Group configuration
|
|
59
|
+
* @param {string} config.name - Group name (e.g. "backend-team")
|
|
60
|
+
* @param {string} [config.runner] - Default runner for agents in this group
|
|
61
|
+
* @param {string} [config.skill] - Default skill for agents in this group
|
|
62
|
+
* @param {string} [config.policy] - Default policy for agents in this group
|
|
63
|
+
* @param {number} [config.max_agents] - Max concurrent agents in this group
|
|
64
|
+
* @param {string[]} [config.include_dirs] - Additional directories agents can access
|
|
65
|
+
* @returns {{ name: string, created: boolean }}
|
|
66
|
+
*/
|
|
67
|
+
export function createGroup(cwd, config) {
|
|
68
|
+
const groups = loadGroups(cwd);
|
|
69
|
+
const existed = !!groups[config.name];
|
|
70
|
+
|
|
71
|
+
groups[config.name] = {
|
|
72
|
+
runner: config.runner || null,
|
|
73
|
+
skill: config.skill || null,
|
|
74
|
+
policy: config.policy || null,
|
|
75
|
+
max_agents: config.max_agents || null,
|
|
76
|
+
include_dirs: config.include_dirs || null,
|
|
77
|
+
created_at: existed ? groups[config.name].created_at : new Date().toISOString(),
|
|
78
|
+
updated_at: new Date().toISOString(),
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
saveGroups(cwd, groups);
|
|
82
|
+
return { name: config.name, created: !existed };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* List all groups.
|
|
87
|
+
*
|
|
88
|
+
* @param {string} cwd - Project directory
|
|
89
|
+
* @returns {Array<{ name: string, runner: string|null, skill: string|null, policy: string|null, max_agents: number|null }>}
|
|
90
|
+
*/
|
|
91
|
+
export function listGroups(cwd) {
|
|
92
|
+
const groups = loadGroups(cwd);
|
|
93
|
+
return Object.entries(groups).map(([name, config]) => ({
|
|
94
|
+
name,
|
|
95
|
+
...config,
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Get a single group by name.
|
|
101
|
+
*
|
|
102
|
+
* @param {string} cwd - Project directory
|
|
103
|
+
* @param {string} name - Group name
|
|
104
|
+
* @returns {object|null}
|
|
105
|
+
*/
|
|
106
|
+
export function getGroup(cwd, name) {
|
|
107
|
+
const groups = loadGroups(cwd);
|
|
108
|
+
return groups[name] || null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Delete a group.
|
|
113
|
+
*
|
|
114
|
+
* @param {string} cwd - Project directory
|
|
115
|
+
* @param {string} name - Group name
|
|
116
|
+
* @returns {boolean}
|
|
117
|
+
*/
|
|
118
|
+
export function deleteGroup(cwd, name) {
|
|
119
|
+
const groups = loadGroups(cwd);
|
|
120
|
+
if (!groups[name]) return false;
|
|
121
|
+
delete groups[name];
|
|
122
|
+
saveGroups(cwd, groups);
|
|
123
|
+
return true;
|
|
124
|
+
}
|