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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-pool-mcp",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "type": "module",
5
5
  "description": "MCP Server for multi-agent task delegation and orchestration via Gemini CLI",
6
6
  "main": "index.js",
@@ -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
@@ -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 a Gemini CLI agent for a pipeline step.
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 PID
177
+ * @returns {number[]} Array of child PIDs
176
178
  */
177
179
  function spawnStep(stepDef, run, runId, bounceReason) {
178
- let prompt = stepDef.prompt;
179
- if (bounceReason) {
180
- prompt = `${stepDef.prompt}\n\n⚠️ BOUNCE BACK: предыдущая попытка была отклонена следующим шагом.\nПричина: ${bounceReason}\nДополни и улучши результат.`;
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
- // Inject pipeline context
184
- 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}".`;
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
- const args = [
187
- '-p', prompt,
188
- '--output-format', 'stream-json',
189
- '--approval-mode', stepDef.approvalMode || 'yolo',
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
- const child = spawn('gemini', args, {
193
- cwd: run.cwd || cwd,
194
- env: { ...process.env, TERM: 'dumb', CI: '1' },
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
- child.on('close', (code) => {
200
- // Update step exit code in run state
201
- try {
202
- const currentRun = JSON.parse(readFileSync(join(cwd, RUNS_DIR, `${runId}.json`), 'utf-8'));
203
- if (currentRun.steps[stepDef.name]) {
204
- currentRun.steps[stepDef.name].exitCode = code;
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
- writeFileSync(join(cwd, RUNS_DIR, `${runId}.json`), JSON.stringify(currentRun, null, 2));
207
- } catch { /* ignore */ }
208
- console.error(`[pipeline] Step "${stepDef.name}" exited (code: ${code}, run: ${runId})`);
209
- });
225
+ }
210
226
 
211
- child.stdin.end();
212
- child.unref();
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
- console.error(`[pipeline] Started step "${stepDef.name}" → pid ${child.pid} (run: ${runId})`);
215
- return child.pid;
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
- step.pid = spawnStep(stepDef, run, runId, step.lastBounceReason);
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' && step.pid) {
271
- if (!isAlive(step.pid)) {
272
- // Process is dead — did agent signal?
273
- if (!step.signaled) {
274
- // Auto-fallback: check exit code
275
- if (step.exitCode === 0 || step.exitCode === null) {
276
- // Treat as success (agent forgot to signal)
277
- step.status = 'success';
278
- step.completedAt = new Date().toISOString();
279
- console.error(`[pipeline] Step "${stepDef.name}" auto-completed (pid dead, exit: ${step.exitCode})`);
280
- } else {
281
- // Failed
282
- step.status = 'failed';
283
- step.completedAt = new Date().toISOString();
284
- console.error(`[pipeline] Step "${stepDef.name}" failed (exit: ${step.exitCode})`);
285
- if (pipeline.onError === 'stop') {
286
- run.status = 'failed';
287
- run.completedAt = new Date().toISOString();
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
- step.pid = spawnStep(stepDef, run, runId);
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
- step.pid = spawnStep(stepDef, run, runId);
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' && step.pid) {
223
- try { process.kill(step.pid, 'SIGTERM'); } catch { /* already dead */ }
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.2.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
+ }
@@ -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
+ }