opencode-swarm-plugin 0.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/.beads/.local_version +1 -0
- package/.beads/README.md +81 -0
- package/.beads/config.yaml +62 -0
- package/.beads/issues.jsonl +549 -0
- package/.beads/metadata.json +4 -0
- package/.gitattributes +3 -0
- package/Dockerfile +30 -0
- package/README.md +312 -0
- package/bun.lock +212 -0
- package/dist/index.js +14627 -0
- package/dist/plugin.js +14562 -0
- package/docker/agent-mail/Dockerfile +23 -0
- package/docker/agent-mail/__pycache__/server.cpython-314.pyc +0 -0
- package/docker/agent-mail/requirements.txt +3 -0
- package/docker/agent-mail/server.py +879 -0
- package/docker-compose.yml +45 -0
- package/package.json +52 -0
- package/scripts/docker-entrypoint.sh +54 -0
- package/src/agent-mail.integration.test.ts +1321 -0
- package/src/agent-mail.ts +665 -0
- package/src/anti-patterns.ts +430 -0
- package/src/beads.integration.test.ts +688 -0
- package/src/beads.ts +603 -0
- package/src/index.ts +267 -0
- package/src/learning.integration.test.ts +1104 -0
- package/src/learning.ts +438 -0
- package/src/pattern-maturity.ts +487 -0
- package/src/plugin.ts +11 -0
- package/src/schemas/bead.ts +152 -0
- package/src/schemas/evaluation.ts +133 -0
- package/src/schemas/index.test.ts +199 -0
- package/src/schemas/index.ts +77 -0
- package/src/schemas/task.ts +129 -0
- package/src/structured.ts +708 -0
- package/src/swarm.integration.test.ts +763 -0
- package/src/swarm.ts +1411 -0
- package/tsconfig.json +28 -0
- package/vitest.integration.config.ts +13 -0
package/src/swarm.ts
ADDED
|
@@ -0,0 +1,1411 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Swarm Module - High-level swarm coordination
|
|
3
|
+
*
|
|
4
|
+
* Orchestrates beads, Agent Mail, and structured validation for parallel task execution.
|
|
5
|
+
* The actual agent spawning happens via OpenCode's Task tool - this module provides
|
|
6
|
+
* the primitives and prompts that /swarm command uses.
|
|
7
|
+
*
|
|
8
|
+
* Key responsibilities:
|
|
9
|
+
* - Task decomposition into bead trees with file assignments
|
|
10
|
+
* - Swarm status tracking via beads + Agent Mail
|
|
11
|
+
* - Progress reporting and completion handling
|
|
12
|
+
* - Prompt templates for decomposition, subtasks, and evaluation
|
|
13
|
+
*/
|
|
14
|
+
import { tool } from "@opencode-ai/plugin";
|
|
15
|
+
import { z } from "zod";
|
|
16
|
+
import {
|
|
17
|
+
BeadTreeSchema,
|
|
18
|
+
SwarmStatusSchema,
|
|
19
|
+
AgentProgressSchema,
|
|
20
|
+
EvaluationSchema,
|
|
21
|
+
BeadSchema,
|
|
22
|
+
type SwarmStatus,
|
|
23
|
+
type AgentProgress,
|
|
24
|
+
type Evaluation,
|
|
25
|
+
type SpawnedAgent,
|
|
26
|
+
type Bead,
|
|
27
|
+
} from "./schemas";
|
|
28
|
+
import { mcpCall } from "./agent-mail";
|
|
29
|
+
import {
|
|
30
|
+
OutcomeSignalsSchema,
|
|
31
|
+
scoreImplicitFeedback,
|
|
32
|
+
outcomeToFeedback,
|
|
33
|
+
type OutcomeSignals,
|
|
34
|
+
type ScoredOutcome,
|
|
35
|
+
type FeedbackEvent,
|
|
36
|
+
DEFAULT_LEARNING_CONFIG,
|
|
37
|
+
} from "./learning";
|
|
38
|
+
|
|
39
|
+
// ============================================================================
|
|
40
|
+
// Conflict Detection
|
|
41
|
+
// ============================================================================
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Marker words that indicate positive directives
|
|
45
|
+
*/
|
|
46
|
+
const POSITIVE_MARKERS = [
|
|
47
|
+
"always",
|
|
48
|
+
"must",
|
|
49
|
+
"required",
|
|
50
|
+
"ensure",
|
|
51
|
+
"use",
|
|
52
|
+
"prefer",
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Marker words that indicate negative directives
|
|
57
|
+
*/
|
|
58
|
+
const NEGATIVE_MARKERS = [
|
|
59
|
+
"never",
|
|
60
|
+
"dont",
|
|
61
|
+
"don't",
|
|
62
|
+
"avoid",
|
|
63
|
+
"forbid",
|
|
64
|
+
"no ",
|
|
65
|
+
"not ",
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A detected conflict between subtask instructions
|
|
70
|
+
*/
|
|
71
|
+
export interface InstructionConflict {
|
|
72
|
+
subtask_a: number;
|
|
73
|
+
subtask_b: number;
|
|
74
|
+
directive_a: string;
|
|
75
|
+
directive_b: string;
|
|
76
|
+
conflict_type: "positive_negative" | "contradictory";
|
|
77
|
+
description: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Extract directives from text based on marker words
|
|
82
|
+
*/
|
|
83
|
+
function extractDirectives(text: string): {
|
|
84
|
+
positive: string[];
|
|
85
|
+
negative: string[];
|
|
86
|
+
} {
|
|
87
|
+
const sentences = text.split(/[.!?\n]+/).map((s) => s.trim().toLowerCase());
|
|
88
|
+
const positive: string[] = [];
|
|
89
|
+
const negative: string[] = [];
|
|
90
|
+
|
|
91
|
+
for (const sentence of sentences) {
|
|
92
|
+
if (!sentence) continue;
|
|
93
|
+
|
|
94
|
+
const hasPositive = POSITIVE_MARKERS.some((m) => sentence.includes(m));
|
|
95
|
+
const hasNegative = NEGATIVE_MARKERS.some((m) => sentence.includes(m));
|
|
96
|
+
|
|
97
|
+
if (hasPositive && !hasNegative) {
|
|
98
|
+
positive.push(sentence);
|
|
99
|
+
} else if (hasNegative) {
|
|
100
|
+
negative.push(sentence);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { positive, negative };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Check if two directives conflict
|
|
109
|
+
*
|
|
110
|
+
* Simple heuristic: look for common subjects with opposite polarity
|
|
111
|
+
*/
|
|
112
|
+
function directivesConflict(positive: string, negative: string): boolean {
|
|
113
|
+
// Extract key nouns/concepts (simple word overlap check)
|
|
114
|
+
const positiveWords = new Set(
|
|
115
|
+
positive.split(/\s+/).filter((w) => w.length > 3),
|
|
116
|
+
);
|
|
117
|
+
const negativeWords = negative.split(/\s+/).filter((w) => w.length > 3);
|
|
118
|
+
|
|
119
|
+
// If they share significant words, they might conflict
|
|
120
|
+
const overlap = negativeWords.filter((w) => positiveWords.has(w));
|
|
121
|
+
return overlap.length >= 2;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Detect conflicts between subtask instructions
|
|
126
|
+
*
|
|
127
|
+
* Looks for cases where one subtask says "always use X" and another says "avoid X".
|
|
128
|
+
*
|
|
129
|
+
* @param subtasks - Array of subtask descriptions
|
|
130
|
+
* @returns Array of detected conflicts
|
|
131
|
+
*
|
|
132
|
+
* @see https://github.com/Dicklesworthstone/cass_memory_system/blob/main/src/curate.ts#L36-L89
|
|
133
|
+
*/
|
|
134
|
+
export function detectInstructionConflicts(
|
|
135
|
+
subtasks: Array<{ title: string; description?: string }>,
|
|
136
|
+
): InstructionConflict[] {
|
|
137
|
+
const conflicts: InstructionConflict[] = [];
|
|
138
|
+
|
|
139
|
+
// Extract directives from each subtask
|
|
140
|
+
const subtaskDirectives = subtasks.map((s, i) => ({
|
|
141
|
+
index: i,
|
|
142
|
+
title: s.title,
|
|
143
|
+
...extractDirectives(`${s.title} ${s.description || ""}`),
|
|
144
|
+
}));
|
|
145
|
+
|
|
146
|
+
// Compare each pair of subtasks
|
|
147
|
+
for (let i = 0; i < subtaskDirectives.length; i++) {
|
|
148
|
+
for (let j = i + 1; j < subtaskDirectives.length; j++) {
|
|
149
|
+
const a = subtaskDirectives[i];
|
|
150
|
+
const b = subtaskDirectives[j];
|
|
151
|
+
|
|
152
|
+
// Check if A's positive conflicts with B's negative
|
|
153
|
+
for (const posA of a.positive) {
|
|
154
|
+
for (const negB of b.negative) {
|
|
155
|
+
if (directivesConflict(posA, negB)) {
|
|
156
|
+
conflicts.push({
|
|
157
|
+
subtask_a: i,
|
|
158
|
+
subtask_b: j,
|
|
159
|
+
directive_a: posA,
|
|
160
|
+
directive_b: negB,
|
|
161
|
+
conflict_type: "positive_negative",
|
|
162
|
+
description: `Subtask ${i} says "${posA}" but subtask ${j} says "${negB}"`,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Check if B's positive conflicts with A's negative
|
|
169
|
+
for (const posB of b.positive) {
|
|
170
|
+
for (const negA of a.negative) {
|
|
171
|
+
if (directivesConflict(posB, negA)) {
|
|
172
|
+
conflicts.push({
|
|
173
|
+
subtask_a: j,
|
|
174
|
+
subtask_b: i,
|
|
175
|
+
directive_a: posB,
|
|
176
|
+
directive_b: negA,
|
|
177
|
+
conflict_type: "positive_negative",
|
|
178
|
+
description: `Subtask ${j} says "${posB}" but subtask ${i} says "${negA}"`,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return conflicts;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ============================================================================
|
|
190
|
+
// Prompt Templates
|
|
191
|
+
// ============================================================================
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Prompt for decomposing a task into parallelizable subtasks.
|
|
195
|
+
*
|
|
196
|
+
* Used by swarm:decompose to instruct the agent on how to break down work.
|
|
197
|
+
* The agent responds with a BeadTree that gets validated.
|
|
198
|
+
*/
|
|
199
|
+
export const DECOMPOSITION_PROMPT = `You are decomposing a task into parallelizable subtasks for a swarm of agents.
|
|
200
|
+
|
|
201
|
+
## Task
|
|
202
|
+
{task}
|
|
203
|
+
|
|
204
|
+
{context_section}
|
|
205
|
+
|
|
206
|
+
## Requirements
|
|
207
|
+
|
|
208
|
+
1. **Break into 2-{max_subtasks} independent subtasks** that can run in parallel
|
|
209
|
+
2. **Assign files** - each subtask must specify which files it will modify
|
|
210
|
+
3. **No file overlap** - files cannot appear in multiple subtasks (they get exclusive locks)
|
|
211
|
+
4. **Order by dependency** - if subtask B needs subtask A's output, A must come first in the array
|
|
212
|
+
5. **Estimate complexity** - 1 (trivial) to 5 (complex)
|
|
213
|
+
|
|
214
|
+
## Response Format
|
|
215
|
+
|
|
216
|
+
Respond with a JSON object matching this schema:
|
|
217
|
+
|
|
218
|
+
\`\`\`typescript
|
|
219
|
+
{
|
|
220
|
+
epic: {
|
|
221
|
+
title: string, // Epic title for the beads tracker
|
|
222
|
+
description?: string // Brief description of the overall goal
|
|
223
|
+
},
|
|
224
|
+
subtasks: [
|
|
225
|
+
{
|
|
226
|
+
title: string, // What this subtask accomplishes
|
|
227
|
+
description?: string, // Detailed instructions for the agent
|
|
228
|
+
files: string[], // Files this subtask will modify (globs allowed)
|
|
229
|
+
dependencies: number[], // Indices of subtasks this depends on (0-indexed)
|
|
230
|
+
estimated_complexity: 1-5 // Effort estimate
|
|
231
|
+
},
|
|
232
|
+
// ... more subtasks
|
|
233
|
+
]
|
|
234
|
+
}
|
|
235
|
+
\`\`\`
|
|
236
|
+
|
|
237
|
+
## Guidelines
|
|
238
|
+
|
|
239
|
+
- **Prefer smaller, focused subtasks** over large complex ones
|
|
240
|
+
- **Include test files** in the same subtask as the code they test
|
|
241
|
+
- **Consider shared types** - if multiple files share types, handle that first
|
|
242
|
+
- **Think about imports** - changes to exported APIs affect downstream files
|
|
243
|
+
|
|
244
|
+
## File Assignment Examples
|
|
245
|
+
|
|
246
|
+
- Schema change: \`["src/schemas/user.ts", "src/schemas/index.ts"]\`
|
|
247
|
+
- Component + test: \`["src/components/Button.tsx", "src/components/Button.test.tsx"]\`
|
|
248
|
+
- API route: \`["src/app/api/users/route.ts"]\`
|
|
249
|
+
|
|
250
|
+
Now decompose the task:`;
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Prompt template for spawned subtask agents.
|
|
254
|
+
*
|
|
255
|
+
* Each agent receives this prompt with their specific subtask details filled in.
|
|
256
|
+
* The prompt establishes context, constraints, and expectations.
|
|
257
|
+
*/
|
|
258
|
+
export const SUBTASK_PROMPT = `You are a swarm agent working on a subtask of a larger epic.
|
|
259
|
+
|
|
260
|
+
## Your Identity
|
|
261
|
+
- **Agent Name**: {agent_name}
|
|
262
|
+
- **Bead ID**: {bead_id}
|
|
263
|
+
- **Epic ID**: {epic_id}
|
|
264
|
+
|
|
265
|
+
## Your Subtask
|
|
266
|
+
**Title**: {subtask_title}
|
|
267
|
+
|
|
268
|
+
{subtask_description}
|
|
269
|
+
|
|
270
|
+
## File Scope
|
|
271
|
+
You have exclusive reservations for these files:
|
|
272
|
+
{file_list}
|
|
273
|
+
|
|
274
|
+
**CRITICAL**: Only modify files in your reservation. If you need to modify other files,
|
|
275
|
+
send a message to the coordinator requesting the change.
|
|
276
|
+
|
|
277
|
+
## Shared Context
|
|
278
|
+
{shared_context}
|
|
279
|
+
|
|
280
|
+
## Coordination Protocol
|
|
281
|
+
|
|
282
|
+
1. **Start**: Your bead is already marked in_progress
|
|
283
|
+
2. **Progress**: Use swarm:progress to report status updates
|
|
284
|
+
3. **Blocked**: If you hit a blocker, report it - don't spin
|
|
285
|
+
4. **Complete**: Use swarm:complete when done - it handles:
|
|
286
|
+
- Closing your bead with a summary
|
|
287
|
+
- Releasing file reservations
|
|
288
|
+
- Notifying the coordinator
|
|
289
|
+
|
|
290
|
+
## Self-Evaluation
|
|
291
|
+
|
|
292
|
+
Before calling swarm:complete, evaluate your work:
|
|
293
|
+
- Type safety: Does it compile without errors?
|
|
294
|
+
- No obvious bugs: Did you handle edge cases?
|
|
295
|
+
- Follows patterns: Does it match existing code style?
|
|
296
|
+
- Readable: Would another developer understand it?
|
|
297
|
+
|
|
298
|
+
If evaluation fails, fix the issues before completing.
|
|
299
|
+
|
|
300
|
+
## Communication
|
|
301
|
+
|
|
302
|
+
To message other agents or the coordinator:
|
|
303
|
+
\`\`\`
|
|
304
|
+
agent-mail:send(
|
|
305
|
+
to: ["coordinator_name" or other agent],
|
|
306
|
+
subject: "Brief subject",
|
|
307
|
+
body: "Message content",
|
|
308
|
+
thread_id: "{epic_id}"
|
|
309
|
+
)
|
|
310
|
+
\`\`\`
|
|
311
|
+
|
|
312
|
+
Begin work on your subtask now.`;
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Prompt for self-evaluation before completing a subtask.
|
|
316
|
+
*
|
|
317
|
+
* Agents use this to assess their work quality before marking complete.
|
|
318
|
+
*/
|
|
319
|
+
export const EVALUATION_PROMPT = `Evaluate the work completed for this subtask.
|
|
320
|
+
|
|
321
|
+
## Subtask
|
|
322
|
+
**Bead ID**: {bead_id}
|
|
323
|
+
**Title**: {subtask_title}
|
|
324
|
+
|
|
325
|
+
## Files Modified
|
|
326
|
+
{files_touched}
|
|
327
|
+
|
|
328
|
+
## Evaluation Criteria
|
|
329
|
+
|
|
330
|
+
For each criterion, assess passed/failed and provide brief feedback:
|
|
331
|
+
|
|
332
|
+
1. **type_safe**: Code compiles without TypeScript errors
|
|
333
|
+
2. **no_bugs**: No obvious bugs, edge cases handled
|
|
334
|
+
3. **patterns**: Follows existing codebase patterns and conventions
|
|
335
|
+
4. **readable**: Code is clear and maintainable
|
|
336
|
+
|
|
337
|
+
## Response Format
|
|
338
|
+
|
|
339
|
+
\`\`\`json
|
|
340
|
+
{
|
|
341
|
+
"passed": boolean, // Overall pass/fail
|
|
342
|
+
"criteria": {
|
|
343
|
+
"type_safe": { "passed": boolean, "feedback": string },
|
|
344
|
+
"no_bugs": { "passed": boolean, "feedback": string },
|
|
345
|
+
"patterns": { "passed": boolean, "feedback": string },
|
|
346
|
+
"readable": { "passed": boolean, "feedback": string }
|
|
347
|
+
},
|
|
348
|
+
"overall_feedback": string,
|
|
349
|
+
"retry_suggestion": string | null // If failed, what to fix
|
|
350
|
+
}
|
|
351
|
+
\`\`\`
|
|
352
|
+
|
|
353
|
+
If any criterion fails, the overall evaluation fails and retry_suggestion
|
|
354
|
+
should describe what needs to be fixed.`;
|
|
355
|
+
|
|
356
|
+
// ============================================================================
|
|
357
|
+
// Errors
|
|
358
|
+
// ============================================================================
|
|
359
|
+
|
|
360
|
+
export class SwarmError extends Error {
|
|
361
|
+
constructor(
|
|
362
|
+
message: string,
|
|
363
|
+
public readonly operation: string,
|
|
364
|
+
public readonly details?: unknown,
|
|
365
|
+
) {
|
|
366
|
+
super(message);
|
|
367
|
+
this.name = "SwarmError";
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export class DecompositionError extends SwarmError {
|
|
372
|
+
constructor(
|
|
373
|
+
message: string,
|
|
374
|
+
public readonly zodError?: z.ZodError,
|
|
375
|
+
) {
|
|
376
|
+
super(message, "decompose", zodError?.issues);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ============================================================================
|
|
381
|
+
// Helper Functions
|
|
382
|
+
// ============================================================================
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Format the decomposition prompt with actual values
|
|
386
|
+
*/
|
|
387
|
+
function formatDecompositionPrompt(
|
|
388
|
+
task: string,
|
|
389
|
+
maxSubtasks: number,
|
|
390
|
+
context?: string,
|
|
391
|
+
): string {
|
|
392
|
+
const contextSection = context
|
|
393
|
+
? `## Additional Context\n${context}`
|
|
394
|
+
: "## Additional Context\n(none provided)";
|
|
395
|
+
|
|
396
|
+
return DECOMPOSITION_PROMPT.replace("{task}", task)
|
|
397
|
+
.replace("{max_subtasks}", maxSubtasks.toString())
|
|
398
|
+
.replace("{context_section}", contextSection);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Format the subtask prompt for a specific agent
|
|
403
|
+
*/
|
|
404
|
+
export function formatSubtaskPrompt(params: {
|
|
405
|
+
agent_name: string;
|
|
406
|
+
bead_id: string;
|
|
407
|
+
epic_id: string;
|
|
408
|
+
subtask_title: string;
|
|
409
|
+
subtask_description: string;
|
|
410
|
+
files: string[];
|
|
411
|
+
shared_context?: string;
|
|
412
|
+
}): string {
|
|
413
|
+
const fileList = params.files.map((f) => `- \`${f}\``).join("\n");
|
|
414
|
+
|
|
415
|
+
return SUBTASK_PROMPT.replace("{agent_name}", params.agent_name)
|
|
416
|
+
.replace("{bead_id}", params.bead_id)
|
|
417
|
+
.replace(/{epic_id}/g, params.epic_id)
|
|
418
|
+
.replace("{subtask_title}", params.subtask_title)
|
|
419
|
+
.replace("{subtask_description}", params.subtask_description || "(none)")
|
|
420
|
+
.replace("{file_list}", fileList || "(no files assigned)")
|
|
421
|
+
.replace("{shared_context}", params.shared_context || "(none)");
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Format the evaluation prompt
|
|
426
|
+
*/
|
|
427
|
+
export function formatEvaluationPrompt(params: {
|
|
428
|
+
bead_id: string;
|
|
429
|
+
subtask_title: string;
|
|
430
|
+
files_touched: string[];
|
|
431
|
+
}): string {
|
|
432
|
+
const filesList = params.files_touched.map((f) => `- \`${f}\``).join("\n");
|
|
433
|
+
|
|
434
|
+
return EVALUATION_PROMPT.replace("{bead_id}", params.bead_id)
|
|
435
|
+
.replace("{subtask_title}", params.subtask_title)
|
|
436
|
+
.replace("{files_touched}", filesList || "(no files recorded)");
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Query beads for subtasks of an epic
|
|
441
|
+
*/
|
|
442
|
+
async function queryEpicSubtasks(epicId: string): Promise<Bead[]> {
|
|
443
|
+
const result = await Bun.$`bd list --parent ${epicId} --json`
|
|
444
|
+
.quiet()
|
|
445
|
+
.nothrow();
|
|
446
|
+
|
|
447
|
+
if (result.exitCode !== 0) {
|
|
448
|
+
throw new SwarmError(
|
|
449
|
+
`Failed to query subtasks: ${result.stderr.toString()}`,
|
|
450
|
+
"query_subtasks",
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
try {
|
|
455
|
+
const parsed = JSON.parse(result.stdout.toString());
|
|
456
|
+
return z.array(BeadSchema).parse(parsed);
|
|
457
|
+
} catch (error) {
|
|
458
|
+
if (error instanceof z.ZodError) {
|
|
459
|
+
throw new SwarmError(
|
|
460
|
+
`Invalid bead data: ${error.message}`,
|
|
461
|
+
"query_subtasks",
|
|
462
|
+
error.issues,
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
throw error;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Query Agent Mail for swarm thread messages
|
|
471
|
+
*/
|
|
472
|
+
async function querySwarmMessages(
|
|
473
|
+
projectKey: string,
|
|
474
|
+
threadId: string,
|
|
475
|
+
): Promise<number> {
|
|
476
|
+
try {
|
|
477
|
+
interface ThreadSummary {
|
|
478
|
+
summary: { total_messages: number };
|
|
479
|
+
}
|
|
480
|
+
const summary = await mcpCall<ThreadSummary>("summarize_thread", {
|
|
481
|
+
project_key: projectKey,
|
|
482
|
+
thread_id: threadId,
|
|
483
|
+
llm_mode: false, // Just need the count
|
|
484
|
+
});
|
|
485
|
+
return summary.summary.total_messages;
|
|
486
|
+
} catch {
|
|
487
|
+
// Thread might not exist yet
|
|
488
|
+
return 0;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Format a progress message for Agent Mail
|
|
494
|
+
*/
|
|
495
|
+
function formatProgressMessage(progress: AgentProgress): string {
|
|
496
|
+
const lines = [
|
|
497
|
+
`**Status**: ${progress.status}`,
|
|
498
|
+
progress.progress_percent !== undefined
|
|
499
|
+
? `**Progress**: ${progress.progress_percent}%`
|
|
500
|
+
: null,
|
|
501
|
+
progress.message ? `**Message**: ${progress.message}` : null,
|
|
502
|
+
progress.files_touched && progress.files_touched.length > 0
|
|
503
|
+
? `**Files touched**:\n${progress.files_touched.map((f) => `- \`${f}\``).join("\n")}`
|
|
504
|
+
: null,
|
|
505
|
+
progress.blockers && progress.blockers.length > 0
|
|
506
|
+
? `**Blockers**:\n${progress.blockers.map((b) => `- ${b}`).join("\n")}`
|
|
507
|
+
: null,
|
|
508
|
+
];
|
|
509
|
+
|
|
510
|
+
return lines.filter(Boolean).join("\n\n");
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// ============================================================================
|
|
514
|
+
// CASS History Integration
|
|
515
|
+
// ============================================================================
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* CASS search result from similar past tasks
|
|
519
|
+
*/
|
|
520
|
+
interface CassSearchResult {
|
|
521
|
+
query: string;
|
|
522
|
+
results: Array<{
|
|
523
|
+
source_path: string;
|
|
524
|
+
line: number;
|
|
525
|
+
agent: string;
|
|
526
|
+
preview: string;
|
|
527
|
+
score: number;
|
|
528
|
+
}>;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Query CASS for similar past tasks
|
|
533
|
+
*
|
|
534
|
+
* @param task - Task description to search for
|
|
535
|
+
* @param limit - Maximum results to return
|
|
536
|
+
* @returns Search results or null if CASS unavailable
|
|
537
|
+
*/
|
|
538
|
+
async function queryCassHistory(
|
|
539
|
+
task: string,
|
|
540
|
+
limit: number = 3,
|
|
541
|
+
): Promise<CassSearchResult | null> {
|
|
542
|
+
try {
|
|
543
|
+
const result = await Bun.$`cass search ${task} --limit ${limit} --json`
|
|
544
|
+
.quiet()
|
|
545
|
+
.nothrow();
|
|
546
|
+
|
|
547
|
+
if (result.exitCode === 127) {
|
|
548
|
+
// CASS not installed
|
|
549
|
+
return null;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
if (result.exitCode !== 0) {
|
|
553
|
+
return null;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const output = result.stdout.toString();
|
|
557
|
+
if (!output.trim()) {
|
|
558
|
+
return { query: task, results: [] };
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
try {
|
|
562
|
+
const parsed = JSON.parse(output);
|
|
563
|
+
return {
|
|
564
|
+
query: task,
|
|
565
|
+
results: Array.isArray(parsed) ? parsed : parsed.results || [],
|
|
566
|
+
};
|
|
567
|
+
} catch {
|
|
568
|
+
return { query: task, results: [] };
|
|
569
|
+
}
|
|
570
|
+
} catch {
|
|
571
|
+
return null;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Format CASS history for inclusion in decomposition prompt
|
|
577
|
+
*/
|
|
578
|
+
function formatCassHistoryForPrompt(history: CassSearchResult): string {
|
|
579
|
+
if (history.results.length === 0) {
|
|
580
|
+
return "";
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const lines = [
|
|
584
|
+
"## Similar Past Tasks",
|
|
585
|
+
"",
|
|
586
|
+
"These similar tasks were found in agent history:",
|
|
587
|
+
"",
|
|
588
|
+
...history.results.slice(0, 3).map((r, i) => {
|
|
589
|
+
const preview = r.preview.slice(0, 200).replace(/\n/g, " ");
|
|
590
|
+
return `${i + 1}. [${r.agent}] ${preview}...`;
|
|
591
|
+
}),
|
|
592
|
+
"",
|
|
593
|
+
"Consider patterns that worked in these past tasks.",
|
|
594
|
+
"",
|
|
595
|
+
];
|
|
596
|
+
|
|
597
|
+
return lines.join("\n");
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// ============================================================================
|
|
601
|
+
// Tool Definitions
|
|
602
|
+
// ============================================================================
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Decompose a task into a bead tree
|
|
606
|
+
*
|
|
607
|
+
* This is a PROMPT tool - it returns a prompt for the agent to respond to.
|
|
608
|
+
* The agent's response (JSON) should be validated with BeadTreeSchema.
|
|
609
|
+
*
|
|
610
|
+
* Optionally queries CASS for similar past tasks to inform decomposition.
|
|
611
|
+
*/
|
|
612
|
+
export const swarm_decompose = tool({
|
|
613
|
+
description:
|
|
614
|
+
"Generate decomposition prompt for breaking task into parallelizable subtasks. Optionally queries CASS for similar past tasks.",
|
|
615
|
+
args: {
|
|
616
|
+
task: tool.schema.string().min(1).describe("Task description to decompose"),
|
|
617
|
+
max_subtasks: tool.schema
|
|
618
|
+
.number()
|
|
619
|
+
.int()
|
|
620
|
+
.min(2)
|
|
621
|
+
.max(10)
|
|
622
|
+
.default(5)
|
|
623
|
+
.describe("Maximum number of subtasks (default: 5)"),
|
|
624
|
+
context: tool.schema
|
|
625
|
+
.string()
|
|
626
|
+
.optional()
|
|
627
|
+
.describe("Additional context (codebase info, constraints, etc.)"),
|
|
628
|
+
query_cass: tool.schema
|
|
629
|
+
.boolean()
|
|
630
|
+
.optional()
|
|
631
|
+
.describe("Query CASS for similar past tasks (default: true)"),
|
|
632
|
+
cass_limit: tool.schema
|
|
633
|
+
.number()
|
|
634
|
+
.int()
|
|
635
|
+
.min(1)
|
|
636
|
+
.max(10)
|
|
637
|
+
.optional()
|
|
638
|
+
.describe("Max CASS results to include (default: 3)"),
|
|
639
|
+
},
|
|
640
|
+
async execute(args) {
|
|
641
|
+
// Query CASS for similar past tasks
|
|
642
|
+
let cassContext = "";
|
|
643
|
+
let cassResult: CassSearchResult | null = null;
|
|
644
|
+
|
|
645
|
+
if (args.query_cass !== false) {
|
|
646
|
+
cassResult = await queryCassHistory(args.task, args.cass_limit ?? 3);
|
|
647
|
+
if (cassResult && cassResult.results.length > 0) {
|
|
648
|
+
cassContext = formatCassHistoryForPrompt(cassResult);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Combine user context with CASS history
|
|
653
|
+
const fullContext = [args.context, cassContext]
|
|
654
|
+
.filter(Boolean)
|
|
655
|
+
.join("\n\n");
|
|
656
|
+
|
|
657
|
+
const prompt = formatDecompositionPrompt(
|
|
658
|
+
args.task,
|
|
659
|
+
args.max_subtasks ?? 5,
|
|
660
|
+
fullContext || undefined,
|
|
661
|
+
);
|
|
662
|
+
|
|
663
|
+
// Return the prompt and schema info for the caller
|
|
664
|
+
return JSON.stringify(
|
|
665
|
+
{
|
|
666
|
+
prompt,
|
|
667
|
+
expected_schema: "BeadTree",
|
|
668
|
+
schema_hint: {
|
|
669
|
+
epic: { title: "string", description: "string?" },
|
|
670
|
+
subtasks: [
|
|
671
|
+
{
|
|
672
|
+
title: "string",
|
|
673
|
+
description: "string?",
|
|
674
|
+
files: "string[]",
|
|
675
|
+
dependencies: "number[]",
|
|
676
|
+
estimated_complexity: "1-5",
|
|
677
|
+
},
|
|
678
|
+
],
|
|
679
|
+
},
|
|
680
|
+
validation_note:
|
|
681
|
+
"Parse agent response as JSON and validate with BeadTreeSchema from schemas/bead.ts",
|
|
682
|
+
cass_history: cassResult
|
|
683
|
+
? {
|
|
684
|
+
queried: true,
|
|
685
|
+
results_found: cassResult.results.length,
|
|
686
|
+
included_in_context: cassResult.results.length > 0,
|
|
687
|
+
}
|
|
688
|
+
: { queried: false, reason: "disabled or unavailable" },
|
|
689
|
+
},
|
|
690
|
+
null,
|
|
691
|
+
2,
|
|
692
|
+
);
|
|
693
|
+
},
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Validate a decomposition response from an agent
|
|
698
|
+
*
|
|
699
|
+
* Use this after the agent responds to swarm:decompose to validate the structure.
|
|
700
|
+
*/
|
|
701
|
+
export const swarm_validate_decomposition = tool({
|
|
702
|
+
description: "Validate a decomposition response against BeadTreeSchema",
|
|
703
|
+
args: {
|
|
704
|
+
response: tool.schema
|
|
705
|
+
.string()
|
|
706
|
+
.describe("JSON response from agent (BeadTree format)"),
|
|
707
|
+
},
|
|
708
|
+
async execute(args) {
|
|
709
|
+
try {
|
|
710
|
+
const parsed = JSON.parse(args.response);
|
|
711
|
+
const validated = BeadTreeSchema.parse(parsed);
|
|
712
|
+
|
|
713
|
+
// Additional validation: check for file conflicts
|
|
714
|
+
const allFiles = new Set<string>();
|
|
715
|
+
const conflicts: string[] = [];
|
|
716
|
+
|
|
717
|
+
for (const subtask of validated.subtasks) {
|
|
718
|
+
for (const file of subtask.files) {
|
|
719
|
+
if (allFiles.has(file)) {
|
|
720
|
+
conflicts.push(file);
|
|
721
|
+
}
|
|
722
|
+
allFiles.add(file);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
if (conflicts.length > 0) {
|
|
727
|
+
return JSON.stringify(
|
|
728
|
+
{
|
|
729
|
+
valid: false,
|
|
730
|
+
error: `File conflicts detected: ${conflicts.join(", ")}`,
|
|
731
|
+
hint: "Each file can only be assigned to one subtask",
|
|
732
|
+
},
|
|
733
|
+
null,
|
|
734
|
+
2,
|
|
735
|
+
);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// Check dependency indices are valid
|
|
739
|
+
for (let i = 0; i < validated.subtasks.length; i++) {
|
|
740
|
+
const deps = validated.subtasks[i].dependencies;
|
|
741
|
+
for (const dep of deps) {
|
|
742
|
+
if (dep >= i) {
|
|
743
|
+
return JSON.stringify(
|
|
744
|
+
{
|
|
745
|
+
valid: false,
|
|
746
|
+
error: `Invalid dependency: subtask ${i} depends on ${dep}, but dependencies must be earlier in the array`,
|
|
747
|
+
hint: "Reorder subtasks so dependencies come before dependents",
|
|
748
|
+
},
|
|
749
|
+
null,
|
|
750
|
+
2,
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// Check for instruction conflicts between subtasks
|
|
757
|
+
const instructionConflicts = detectInstructionConflicts(
|
|
758
|
+
validated.subtasks,
|
|
759
|
+
);
|
|
760
|
+
|
|
761
|
+
return JSON.stringify(
|
|
762
|
+
{
|
|
763
|
+
valid: true,
|
|
764
|
+
bead_tree: validated,
|
|
765
|
+
stats: {
|
|
766
|
+
subtask_count: validated.subtasks.length,
|
|
767
|
+
total_files: allFiles.size,
|
|
768
|
+
total_complexity: validated.subtasks.reduce(
|
|
769
|
+
(sum, s) => sum + s.estimated_complexity,
|
|
770
|
+
0,
|
|
771
|
+
),
|
|
772
|
+
},
|
|
773
|
+
// Include conflicts as warnings (not blocking)
|
|
774
|
+
warnings:
|
|
775
|
+
instructionConflicts.length > 0
|
|
776
|
+
? {
|
|
777
|
+
instruction_conflicts: instructionConflicts,
|
|
778
|
+
hint: "Review these potential conflicts between subtask instructions",
|
|
779
|
+
}
|
|
780
|
+
: undefined,
|
|
781
|
+
},
|
|
782
|
+
null,
|
|
783
|
+
2,
|
|
784
|
+
);
|
|
785
|
+
} catch (error) {
|
|
786
|
+
if (error instanceof z.ZodError) {
|
|
787
|
+
return JSON.stringify(
|
|
788
|
+
{
|
|
789
|
+
valid: false,
|
|
790
|
+
error: "Schema validation failed",
|
|
791
|
+
details: error.issues,
|
|
792
|
+
},
|
|
793
|
+
null,
|
|
794
|
+
2,
|
|
795
|
+
);
|
|
796
|
+
}
|
|
797
|
+
if (error instanceof SyntaxError) {
|
|
798
|
+
return JSON.stringify(
|
|
799
|
+
{
|
|
800
|
+
valid: false,
|
|
801
|
+
error: "Invalid JSON",
|
|
802
|
+
details: error.message,
|
|
803
|
+
},
|
|
804
|
+
null,
|
|
805
|
+
2,
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
throw error;
|
|
809
|
+
}
|
|
810
|
+
},
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* Get status of a swarm by epic ID
|
|
815
|
+
*
|
|
816
|
+
* Requires project_key to query Agent Mail for message counts.
|
|
817
|
+
*/
|
|
818
|
+
export const swarm_status = tool({
|
|
819
|
+
description: "Get status of a swarm by epic ID",
|
|
820
|
+
args: {
|
|
821
|
+
epic_id: tool.schema.string().describe("Epic bead ID (e.g., bd-abc123)"),
|
|
822
|
+
project_key: tool.schema
|
|
823
|
+
.string()
|
|
824
|
+
.describe("Project path (for Agent Mail queries)"),
|
|
825
|
+
},
|
|
826
|
+
async execute(args) {
|
|
827
|
+
// Query subtasks from beads
|
|
828
|
+
const subtasks = await queryEpicSubtasks(args.epic_id);
|
|
829
|
+
|
|
830
|
+
// Count statuses
|
|
831
|
+
const statusCounts = {
|
|
832
|
+
running: 0,
|
|
833
|
+
completed: 0,
|
|
834
|
+
failed: 0,
|
|
835
|
+
blocked: 0,
|
|
836
|
+
};
|
|
837
|
+
|
|
838
|
+
const agents: SpawnedAgent[] = [];
|
|
839
|
+
|
|
840
|
+
for (const bead of subtasks) {
|
|
841
|
+
// Map bead status to agent status
|
|
842
|
+
let agentStatus: SpawnedAgent["status"] = "pending";
|
|
843
|
+
switch (bead.status) {
|
|
844
|
+
case "in_progress":
|
|
845
|
+
agentStatus = "running";
|
|
846
|
+
statusCounts.running++;
|
|
847
|
+
break;
|
|
848
|
+
case "closed":
|
|
849
|
+
agentStatus = "completed";
|
|
850
|
+
statusCounts.completed++;
|
|
851
|
+
break;
|
|
852
|
+
case "blocked":
|
|
853
|
+
agentStatus = "pending"; // Blocked treated as pending for swarm
|
|
854
|
+
statusCounts.blocked++;
|
|
855
|
+
break;
|
|
856
|
+
default:
|
|
857
|
+
// open = pending
|
|
858
|
+
break;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
agents.push({
|
|
862
|
+
bead_id: bead.id,
|
|
863
|
+
agent_name: "", // We don't track this in beads
|
|
864
|
+
status: agentStatus,
|
|
865
|
+
files: [], // Would need to parse from description
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// Query Agent Mail for message activity
|
|
870
|
+
const messageCount = await querySwarmMessages(
|
|
871
|
+
args.project_key,
|
|
872
|
+
args.epic_id,
|
|
873
|
+
);
|
|
874
|
+
|
|
875
|
+
const status: SwarmStatus = {
|
|
876
|
+
epic_id: args.epic_id,
|
|
877
|
+
total_agents: subtasks.length,
|
|
878
|
+
running: statusCounts.running,
|
|
879
|
+
completed: statusCounts.completed,
|
|
880
|
+
failed: statusCounts.failed,
|
|
881
|
+
blocked: statusCounts.blocked,
|
|
882
|
+
agents,
|
|
883
|
+
last_update: new Date().toISOString(),
|
|
884
|
+
};
|
|
885
|
+
|
|
886
|
+
// Validate and return
|
|
887
|
+
const validated = SwarmStatusSchema.parse(status);
|
|
888
|
+
|
|
889
|
+
return JSON.stringify(
|
|
890
|
+
{
|
|
891
|
+
...validated,
|
|
892
|
+
message_count: messageCount,
|
|
893
|
+
progress_percent:
|
|
894
|
+
subtasks.length > 0
|
|
895
|
+
? Math.round((statusCounts.completed / subtasks.length) * 100)
|
|
896
|
+
: 0,
|
|
897
|
+
},
|
|
898
|
+
null,
|
|
899
|
+
2,
|
|
900
|
+
);
|
|
901
|
+
},
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* Report progress on a subtask
|
|
906
|
+
*
|
|
907
|
+
* Takes explicit agent identity since tools don't have persistent state.
|
|
908
|
+
*/
|
|
909
|
+
export const swarm_progress = tool({
|
|
910
|
+
description: "Report progress on a subtask to coordinator",
|
|
911
|
+
args: {
|
|
912
|
+
project_key: tool.schema.string().describe("Project path"),
|
|
913
|
+
agent_name: tool.schema.string().describe("Your Agent Mail name"),
|
|
914
|
+
bead_id: tool.schema.string().describe("Subtask bead ID"),
|
|
915
|
+
status: tool.schema
|
|
916
|
+
.enum(["in_progress", "blocked", "completed", "failed"])
|
|
917
|
+
.describe("Current status"),
|
|
918
|
+
message: tool.schema
|
|
919
|
+
.string()
|
|
920
|
+
.optional()
|
|
921
|
+
.describe("Progress message or blockers"),
|
|
922
|
+
progress_percent: tool.schema
|
|
923
|
+
.number()
|
|
924
|
+
.min(0)
|
|
925
|
+
.max(100)
|
|
926
|
+
.optional()
|
|
927
|
+
.describe("Completion percentage"),
|
|
928
|
+
files_touched: tool.schema
|
|
929
|
+
.array(tool.schema.string())
|
|
930
|
+
.optional()
|
|
931
|
+
.describe("Files modified so far"),
|
|
932
|
+
},
|
|
933
|
+
async execute(args) {
|
|
934
|
+
// Build progress report
|
|
935
|
+
const progress: AgentProgress = {
|
|
936
|
+
bead_id: args.bead_id,
|
|
937
|
+
agent_name: args.agent_name,
|
|
938
|
+
status: args.status,
|
|
939
|
+
progress_percent: args.progress_percent,
|
|
940
|
+
message: args.message,
|
|
941
|
+
files_touched: args.files_touched,
|
|
942
|
+
timestamp: new Date().toISOString(),
|
|
943
|
+
};
|
|
944
|
+
|
|
945
|
+
// Validate
|
|
946
|
+
const validated = AgentProgressSchema.parse(progress);
|
|
947
|
+
|
|
948
|
+
// Update bead status if needed
|
|
949
|
+
if (args.status === "blocked" || args.status === "in_progress") {
|
|
950
|
+
const beadStatus = args.status === "blocked" ? "blocked" : "in_progress";
|
|
951
|
+
await Bun.$`bd update ${args.bead_id} --status ${beadStatus} --json`
|
|
952
|
+
.quiet()
|
|
953
|
+
.nothrow();
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// Extract epic ID from bead ID (e.g., bd-abc123.1 -> bd-abc123)
|
|
957
|
+
const epicId = args.bead_id.includes(".")
|
|
958
|
+
? args.bead_id.split(".")[0]
|
|
959
|
+
: args.bead_id;
|
|
960
|
+
|
|
961
|
+
// Send progress message to thread
|
|
962
|
+
await mcpCall("send_message", {
|
|
963
|
+
project_key: args.project_key,
|
|
964
|
+
sender_name: args.agent_name,
|
|
965
|
+
to: [], // Coordinator will pick it up from thread
|
|
966
|
+
subject: `Progress: ${args.bead_id} - ${args.status}`,
|
|
967
|
+
body_md: formatProgressMessage(validated),
|
|
968
|
+
thread_id: epicId,
|
|
969
|
+
importance: args.status === "blocked" ? "high" : "normal",
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
return `Progress reported: ${args.status}${args.progress_percent !== undefined ? ` (${args.progress_percent}%)` : ""}`;
|
|
973
|
+
},
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
/**
|
|
977
|
+
* UBS scan result schema
|
|
978
|
+
*/
|
|
979
|
+
interface UbsScanResult {
|
|
980
|
+
exitCode: number;
|
|
981
|
+
bugs: Array<{
|
|
982
|
+
file: string;
|
|
983
|
+
line: number;
|
|
984
|
+
severity: string;
|
|
985
|
+
message: string;
|
|
986
|
+
category: string;
|
|
987
|
+
}>;
|
|
988
|
+
summary: {
|
|
989
|
+
total: number;
|
|
990
|
+
critical: number;
|
|
991
|
+
high: number;
|
|
992
|
+
medium: number;
|
|
993
|
+
low: number;
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/**
|
|
998
|
+
* Run UBS scan on files before completion
|
|
999
|
+
*
|
|
1000
|
+
* @param files - Files to scan
|
|
1001
|
+
* @returns Scan result or null if UBS not available
|
|
1002
|
+
*/
|
|
1003
|
+
async function runUbsScan(files: string[]): Promise<UbsScanResult | null> {
|
|
1004
|
+
if (files.length === 0) {
|
|
1005
|
+
return null;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
try {
|
|
1009
|
+
// Run UBS scan with JSON output
|
|
1010
|
+
const result = await Bun.$`ubs scan ${files.join(" ")} --json`
|
|
1011
|
+
.quiet()
|
|
1012
|
+
.nothrow();
|
|
1013
|
+
|
|
1014
|
+
if (result.exitCode === 127) {
|
|
1015
|
+
// UBS not installed
|
|
1016
|
+
return null;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
const output = result.stdout.toString();
|
|
1020
|
+
if (!output.trim()) {
|
|
1021
|
+
return {
|
|
1022
|
+
exitCode: result.exitCode,
|
|
1023
|
+
bugs: [],
|
|
1024
|
+
summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0 },
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
try {
|
|
1029
|
+
const parsed = JSON.parse(output);
|
|
1030
|
+
return {
|
|
1031
|
+
exitCode: result.exitCode,
|
|
1032
|
+
bugs: parsed.bugs || [],
|
|
1033
|
+
summary: parsed.summary || {
|
|
1034
|
+
total: 0,
|
|
1035
|
+
critical: 0,
|
|
1036
|
+
high: 0,
|
|
1037
|
+
medium: 0,
|
|
1038
|
+
low: 0,
|
|
1039
|
+
},
|
|
1040
|
+
};
|
|
1041
|
+
} catch {
|
|
1042
|
+
// UBS output wasn't JSON, return basic result
|
|
1043
|
+
return {
|
|
1044
|
+
exitCode: result.exitCode,
|
|
1045
|
+
bugs: [],
|
|
1046
|
+
summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0 },
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
} catch {
|
|
1050
|
+
return null;
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
/**
|
|
1055
|
+
* Mark a subtask as complete
|
|
1056
|
+
*
|
|
1057
|
+
* Closes bead, releases reservations, notifies coordinator.
|
|
1058
|
+
* Optionally runs UBS scan on modified files before completion.
|
|
1059
|
+
*/
|
|
1060
|
+
export const swarm_complete = tool({
|
|
1061
|
+
description:
|
|
1062
|
+
"Mark subtask complete, release reservations, notify coordinator. Runs UBS bug scan if files_touched provided.",
|
|
1063
|
+
args: {
|
|
1064
|
+
project_key: tool.schema.string().describe("Project path"),
|
|
1065
|
+
agent_name: tool.schema.string().describe("Your Agent Mail name"),
|
|
1066
|
+
bead_id: tool.schema.string().describe("Subtask bead ID"),
|
|
1067
|
+
summary: tool.schema.string().describe("Brief summary of work done"),
|
|
1068
|
+
evaluation: tool.schema
|
|
1069
|
+
.string()
|
|
1070
|
+
.optional()
|
|
1071
|
+
.describe("Self-evaluation JSON (Evaluation schema)"),
|
|
1072
|
+
files_touched: tool.schema
|
|
1073
|
+
.array(tool.schema.string())
|
|
1074
|
+
.optional()
|
|
1075
|
+
.describe("Files modified - will be scanned by UBS for bugs"),
|
|
1076
|
+
skip_ubs_scan: tool.schema
|
|
1077
|
+
.boolean()
|
|
1078
|
+
.optional()
|
|
1079
|
+
.describe("Skip UBS bug scan (default: false)"),
|
|
1080
|
+
},
|
|
1081
|
+
async execute(args) {
|
|
1082
|
+
// Run UBS scan on modified files if provided
|
|
1083
|
+
let ubsResult: UbsScanResult | null = null;
|
|
1084
|
+
if (
|
|
1085
|
+
args.files_touched &&
|
|
1086
|
+
args.files_touched.length > 0 &&
|
|
1087
|
+
!args.skip_ubs_scan
|
|
1088
|
+
) {
|
|
1089
|
+
ubsResult = await runUbsScan(args.files_touched);
|
|
1090
|
+
|
|
1091
|
+
// Block completion if critical bugs found
|
|
1092
|
+
if (ubsResult && ubsResult.summary.critical > 0) {
|
|
1093
|
+
return JSON.stringify(
|
|
1094
|
+
{
|
|
1095
|
+
success: false,
|
|
1096
|
+
error: "UBS found critical bugs - fix before completing",
|
|
1097
|
+
ubs_scan: {
|
|
1098
|
+
critical_count: ubsResult.summary.critical,
|
|
1099
|
+
bugs: ubsResult.bugs.filter((b) => b.severity === "critical"),
|
|
1100
|
+
},
|
|
1101
|
+
hint: "Fix the critical bugs and try again, or use skip_ubs_scan=true to bypass",
|
|
1102
|
+
},
|
|
1103
|
+
null,
|
|
1104
|
+
2,
|
|
1105
|
+
);
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
// Parse and validate evaluation if provided
|
|
1110
|
+
let parsedEvaluation: Evaluation | undefined;
|
|
1111
|
+
if (args.evaluation) {
|
|
1112
|
+
try {
|
|
1113
|
+
parsedEvaluation = EvaluationSchema.parse(JSON.parse(args.evaluation));
|
|
1114
|
+
} catch (error) {
|
|
1115
|
+
return JSON.stringify(
|
|
1116
|
+
{
|
|
1117
|
+
success: false,
|
|
1118
|
+
error: "Invalid evaluation format",
|
|
1119
|
+
details: error instanceof z.ZodError ? error.issues : String(error),
|
|
1120
|
+
},
|
|
1121
|
+
null,
|
|
1122
|
+
2,
|
|
1123
|
+
);
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
// If evaluation failed, don't complete
|
|
1127
|
+
if (!parsedEvaluation.passed) {
|
|
1128
|
+
return JSON.stringify(
|
|
1129
|
+
{
|
|
1130
|
+
success: false,
|
|
1131
|
+
error: "Self-evaluation failed",
|
|
1132
|
+
retry_suggestion: parsedEvaluation.retry_suggestion,
|
|
1133
|
+
feedback: parsedEvaluation.overall_feedback,
|
|
1134
|
+
},
|
|
1135
|
+
null,
|
|
1136
|
+
2,
|
|
1137
|
+
);
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
// Close the bead
|
|
1142
|
+
const closeResult =
|
|
1143
|
+
await Bun.$`bd close ${args.bead_id} --reason ${args.summary} --json`
|
|
1144
|
+
.quiet()
|
|
1145
|
+
.nothrow();
|
|
1146
|
+
|
|
1147
|
+
if (closeResult.exitCode !== 0) {
|
|
1148
|
+
throw new SwarmError(
|
|
1149
|
+
`Failed to close bead: ${closeResult.stderr.toString()}`,
|
|
1150
|
+
"complete",
|
|
1151
|
+
);
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
// Release file reservations for this agent
|
|
1155
|
+
await mcpCall("release_file_reservations", {
|
|
1156
|
+
project_key: args.project_key,
|
|
1157
|
+
agent_name: args.agent_name,
|
|
1158
|
+
});
|
|
1159
|
+
|
|
1160
|
+
// Extract epic ID
|
|
1161
|
+
const epicId = args.bead_id.includes(".")
|
|
1162
|
+
? args.bead_id.split(".")[0]
|
|
1163
|
+
: args.bead_id;
|
|
1164
|
+
|
|
1165
|
+
// Send completion message
|
|
1166
|
+
const completionBody = [
|
|
1167
|
+
`## Subtask Complete: ${args.bead_id}`,
|
|
1168
|
+
"",
|
|
1169
|
+
`**Summary**: ${args.summary}`,
|
|
1170
|
+
"",
|
|
1171
|
+
parsedEvaluation
|
|
1172
|
+
? `**Self-Evaluation**: ${parsedEvaluation.passed ? "PASSED" : "FAILED"}`
|
|
1173
|
+
: "",
|
|
1174
|
+
parsedEvaluation?.overall_feedback
|
|
1175
|
+
? `**Feedback**: ${parsedEvaluation.overall_feedback}`
|
|
1176
|
+
: "",
|
|
1177
|
+
]
|
|
1178
|
+
.filter(Boolean)
|
|
1179
|
+
.join("\n");
|
|
1180
|
+
|
|
1181
|
+
await mcpCall("send_message", {
|
|
1182
|
+
project_key: args.project_key,
|
|
1183
|
+
sender_name: args.agent_name,
|
|
1184
|
+
to: [], // Thread broadcast
|
|
1185
|
+
subject: `Complete: ${args.bead_id}`,
|
|
1186
|
+
body_md: completionBody,
|
|
1187
|
+
thread_id: epicId,
|
|
1188
|
+
importance: "normal",
|
|
1189
|
+
});
|
|
1190
|
+
|
|
1191
|
+
return JSON.stringify(
|
|
1192
|
+
{
|
|
1193
|
+
success: true,
|
|
1194
|
+
bead_id: args.bead_id,
|
|
1195
|
+
closed: true,
|
|
1196
|
+
reservations_released: true,
|
|
1197
|
+
message_sent: true,
|
|
1198
|
+
ubs_scan: ubsResult
|
|
1199
|
+
? {
|
|
1200
|
+
ran: true,
|
|
1201
|
+
bugs_found: ubsResult.summary.total,
|
|
1202
|
+
summary: ubsResult.summary,
|
|
1203
|
+
warnings: ubsResult.bugs.filter((b) => b.severity !== "critical"),
|
|
1204
|
+
}
|
|
1205
|
+
: {
|
|
1206
|
+
ran: false,
|
|
1207
|
+
reason: args.skip_ubs_scan
|
|
1208
|
+
? "skipped"
|
|
1209
|
+
: "no files or ubs unavailable",
|
|
1210
|
+
},
|
|
1211
|
+
},
|
|
1212
|
+
null,
|
|
1213
|
+
2,
|
|
1214
|
+
);
|
|
1215
|
+
},
|
|
1216
|
+
});
|
|
1217
|
+
|
|
1218
|
+
/**
|
|
1219
|
+
* Record outcome signals from a completed subtask
|
|
1220
|
+
*
|
|
1221
|
+
* Tracks implicit feedback (duration, errors, retries) to score
|
|
1222
|
+
* decomposition quality over time. This data feeds into criterion
|
|
1223
|
+
* weight calculations.
|
|
1224
|
+
*
|
|
1225
|
+
* @see src/learning.ts for scoring logic
|
|
1226
|
+
*/
|
|
1227
|
+
export const swarm_record_outcome = tool({
|
|
1228
|
+
description:
|
|
1229
|
+
"Record subtask outcome for implicit feedback scoring. Tracks duration, errors, retries to learn decomposition quality.",
|
|
1230
|
+
args: {
|
|
1231
|
+
bead_id: tool.schema.string().describe("Subtask bead ID"),
|
|
1232
|
+
duration_ms: tool.schema
|
|
1233
|
+
.number()
|
|
1234
|
+
.int()
|
|
1235
|
+
.min(0)
|
|
1236
|
+
.describe("Duration in milliseconds"),
|
|
1237
|
+
error_count: tool.schema
|
|
1238
|
+
.number()
|
|
1239
|
+
.int()
|
|
1240
|
+
.min(0)
|
|
1241
|
+
.default(0)
|
|
1242
|
+
.describe("Number of errors encountered"),
|
|
1243
|
+
retry_count: tool.schema
|
|
1244
|
+
.number()
|
|
1245
|
+
.int()
|
|
1246
|
+
.min(0)
|
|
1247
|
+
.default(0)
|
|
1248
|
+
.describe("Number of retry attempts"),
|
|
1249
|
+
success: tool.schema.boolean().describe("Whether the subtask succeeded"),
|
|
1250
|
+
files_touched: tool.schema
|
|
1251
|
+
.array(tool.schema.string())
|
|
1252
|
+
.optional()
|
|
1253
|
+
.describe("Files that were modified"),
|
|
1254
|
+
criteria: tool.schema
|
|
1255
|
+
.array(tool.schema.string())
|
|
1256
|
+
.optional()
|
|
1257
|
+
.describe(
|
|
1258
|
+
"Criteria to generate feedback for (default: all default criteria)",
|
|
1259
|
+
),
|
|
1260
|
+
},
|
|
1261
|
+
async execute(args) {
|
|
1262
|
+
// Build outcome signals
|
|
1263
|
+
const signals: OutcomeSignals = {
|
|
1264
|
+
bead_id: args.bead_id,
|
|
1265
|
+
duration_ms: args.duration_ms,
|
|
1266
|
+
error_count: args.error_count ?? 0,
|
|
1267
|
+
retry_count: args.retry_count ?? 0,
|
|
1268
|
+
success: args.success,
|
|
1269
|
+
files_touched: args.files_touched ?? [],
|
|
1270
|
+
timestamp: new Date().toISOString(),
|
|
1271
|
+
};
|
|
1272
|
+
|
|
1273
|
+
// Validate signals
|
|
1274
|
+
const validated = OutcomeSignalsSchema.parse(signals);
|
|
1275
|
+
|
|
1276
|
+
// Score the outcome
|
|
1277
|
+
const scored: ScoredOutcome = scoreImplicitFeedback(
|
|
1278
|
+
validated,
|
|
1279
|
+
DEFAULT_LEARNING_CONFIG,
|
|
1280
|
+
);
|
|
1281
|
+
|
|
1282
|
+
// Generate feedback events for each criterion
|
|
1283
|
+
const criteriaToScore = args.criteria ?? [
|
|
1284
|
+
"type_safe",
|
|
1285
|
+
"no_bugs",
|
|
1286
|
+
"patterns",
|
|
1287
|
+
"readable",
|
|
1288
|
+
];
|
|
1289
|
+
const feedbackEvents: FeedbackEvent[] = criteriaToScore.map((criterion) =>
|
|
1290
|
+
outcomeToFeedback(scored, criterion),
|
|
1291
|
+
);
|
|
1292
|
+
|
|
1293
|
+
return JSON.stringify(
|
|
1294
|
+
{
|
|
1295
|
+
success: true,
|
|
1296
|
+
outcome: {
|
|
1297
|
+
signals: validated,
|
|
1298
|
+
scored: {
|
|
1299
|
+
type: scored.type,
|
|
1300
|
+
decayed_value: scored.decayed_value,
|
|
1301
|
+
reasoning: scored.reasoning,
|
|
1302
|
+
},
|
|
1303
|
+
},
|
|
1304
|
+
feedback_events: feedbackEvents,
|
|
1305
|
+
summary: {
|
|
1306
|
+
feedback_type: scored.type,
|
|
1307
|
+
duration_seconds: Math.round(args.duration_ms / 1000),
|
|
1308
|
+
error_count: args.error_count ?? 0,
|
|
1309
|
+
retry_count: args.retry_count ?? 0,
|
|
1310
|
+
success: args.success,
|
|
1311
|
+
},
|
|
1312
|
+
note: "Feedback events should be stored for criterion weight calculation. Use learning.ts functions to apply weights.",
|
|
1313
|
+
},
|
|
1314
|
+
null,
|
|
1315
|
+
2,
|
|
1316
|
+
);
|
|
1317
|
+
},
|
|
1318
|
+
});
|
|
1319
|
+
|
|
1320
|
+
/**
|
|
1321
|
+
* Generate subtask prompt for a spawned agent
|
|
1322
|
+
*/
|
|
1323
|
+
export const swarm_subtask_prompt = tool({
|
|
1324
|
+
description: "Generate the prompt for a spawned subtask agent",
|
|
1325
|
+
args: {
|
|
1326
|
+
agent_name: tool.schema.string().describe("Agent Mail name for the agent"),
|
|
1327
|
+
bead_id: tool.schema.string().describe("Subtask bead ID"),
|
|
1328
|
+
epic_id: tool.schema.string().describe("Epic bead ID"),
|
|
1329
|
+
subtask_title: tool.schema.string().describe("Subtask title"),
|
|
1330
|
+
subtask_description: tool.schema
|
|
1331
|
+
.string()
|
|
1332
|
+
.optional()
|
|
1333
|
+
.describe("Detailed subtask instructions"),
|
|
1334
|
+
files: tool.schema
|
|
1335
|
+
.array(tool.schema.string())
|
|
1336
|
+
.describe("Files assigned to this subtask"),
|
|
1337
|
+
shared_context: tool.schema
|
|
1338
|
+
.string()
|
|
1339
|
+
.optional()
|
|
1340
|
+
.describe("Context shared across all agents"),
|
|
1341
|
+
},
|
|
1342
|
+
async execute(args) {
|
|
1343
|
+
const prompt = formatSubtaskPrompt({
|
|
1344
|
+
agent_name: args.agent_name,
|
|
1345
|
+
bead_id: args.bead_id,
|
|
1346
|
+
epic_id: args.epic_id,
|
|
1347
|
+
subtask_title: args.subtask_title,
|
|
1348
|
+
subtask_description: args.subtask_description || "",
|
|
1349
|
+
files: args.files,
|
|
1350
|
+
shared_context: args.shared_context,
|
|
1351
|
+
});
|
|
1352
|
+
|
|
1353
|
+
return prompt;
|
|
1354
|
+
},
|
|
1355
|
+
});
|
|
1356
|
+
|
|
1357
|
+
/**
|
|
1358
|
+
* Generate self-evaluation prompt
|
|
1359
|
+
*/
|
|
1360
|
+
export const swarm_evaluation_prompt = tool({
|
|
1361
|
+
description: "Generate self-evaluation prompt for a completed subtask",
|
|
1362
|
+
args: {
|
|
1363
|
+
bead_id: tool.schema.string().describe("Subtask bead ID"),
|
|
1364
|
+
subtask_title: tool.schema.string().describe("Subtask title"),
|
|
1365
|
+
files_touched: tool.schema
|
|
1366
|
+
.array(tool.schema.string())
|
|
1367
|
+
.describe("Files that were modified"),
|
|
1368
|
+
},
|
|
1369
|
+
async execute(args) {
|
|
1370
|
+
const prompt = formatEvaluationPrompt({
|
|
1371
|
+
bead_id: args.bead_id,
|
|
1372
|
+
subtask_title: args.subtask_title,
|
|
1373
|
+
files_touched: args.files_touched,
|
|
1374
|
+
});
|
|
1375
|
+
|
|
1376
|
+
return JSON.stringify(
|
|
1377
|
+
{
|
|
1378
|
+
prompt,
|
|
1379
|
+
expected_schema: "Evaluation",
|
|
1380
|
+
schema_hint: {
|
|
1381
|
+
passed: "boolean",
|
|
1382
|
+
criteria: {
|
|
1383
|
+
type_safe: { passed: "boolean", feedback: "string" },
|
|
1384
|
+
no_bugs: { passed: "boolean", feedback: "string" },
|
|
1385
|
+
patterns: { passed: "boolean", feedback: "string" },
|
|
1386
|
+
readable: { passed: "boolean", feedback: "string" },
|
|
1387
|
+
},
|
|
1388
|
+
overall_feedback: "string",
|
|
1389
|
+
retry_suggestion: "string | null",
|
|
1390
|
+
},
|
|
1391
|
+
},
|
|
1392
|
+
null,
|
|
1393
|
+
2,
|
|
1394
|
+
);
|
|
1395
|
+
},
|
|
1396
|
+
});
|
|
1397
|
+
|
|
1398
|
+
// ============================================================================
|
|
1399
|
+
// Export all tools
|
|
1400
|
+
// ============================================================================
|
|
1401
|
+
|
|
1402
|
+
export const swarmTools = {
|
|
1403
|
+
swarm_decompose: swarm_decompose,
|
|
1404
|
+
swarm_validate_decomposition: swarm_validate_decomposition,
|
|
1405
|
+
swarm_status: swarm_status,
|
|
1406
|
+
swarm_progress: swarm_progress,
|
|
1407
|
+
swarm_complete: swarm_complete,
|
|
1408
|
+
swarm_record_outcome: swarm_record_outcome,
|
|
1409
|
+
swarm_subtask_prompt: swarm_subtask_prompt,
|
|
1410
|
+
swarm_evaluation_prompt: swarm_evaluation_prompt,
|
|
1411
|
+
};
|