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.
@@ -0,0 +1,708 @@
1
+ /**
2
+ * Structured Output Module - Validate and parse agent responses
3
+ *
4
+ * Agents frequently return malformed JSON, especially when streaming
5
+ * or under high load. This module provides robust extraction and
6
+ * validation with detailed error feedback for retry loops.
7
+ *
8
+ * Key patterns:
9
+ * - Multiple JSON extraction strategies (direct, code blocks, brace matching)
10
+ * - Zod schema validation with formatted error messages
11
+ * - Structured error types for programmatic handling
12
+ */
13
+ import { tool } from "@opencode-ai/plugin";
14
+ import { z, type ZodSchema } from "zod";
15
+ import {
16
+ EvaluationSchema,
17
+ TaskDecompositionSchema,
18
+ BeadTreeSchema,
19
+ ValidationResultSchema,
20
+ type Evaluation,
21
+ type TaskDecomposition,
22
+ type BeadTree,
23
+ type ValidationResult,
24
+ } from "./schemas";
25
+
26
+ // ============================================================================
27
+ // Error Types
28
+ // ============================================================================
29
+
30
+ /**
31
+ * Structured validation error with formatted feedback
32
+ *
33
+ * Contains both raw Zod errors for programmatic access and
34
+ * pre-formatted error bullets suitable for retry prompts.
35
+ */
36
+ export class StructuredValidationError extends Error {
37
+ public readonly errorBullets: string[];
38
+
39
+ constructor(
40
+ message: string,
41
+ public readonly zodError: z.ZodError | null,
42
+ public readonly rawInput: string,
43
+ public readonly extractionMethod?: string,
44
+ ) {
45
+ super(message);
46
+ this.name = "StructuredValidationError";
47
+ this.errorBullets = zodError ? formatZodErrors(zodError) : [message];
48
+ }
49
+
50
+ /**
51
+ * Format errors as bullet list for retry prompts
52
+ */
53
+ toFeedback(): string {
54
+ return this.errorBullets.map((e) => `- ${e}`).join("\n");
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Error when JSON cannot be extracted from text
60
+ */
61
+ export class JsonExtractionError extends Error {
62
+ constructor(
63
+ message: string,
64
+ public readonly rawInput: string,
65
+ public readonly attemptedStrategies: string[],
66
+ ) {
67
+ super(message);
68
+ this.name = "JsonExtractionError";
69
+ }
70
+ }
71
+
72
+ // ============================================================================
73
+ // Helper Functions
74
+ // ============================================================================
75
+
76
+ /**
77
+ * Format Zod validation errors as readable bullet points
78
+ *
79
+ * @param error - Zod error from schema validation
80
+ * @returns Array of error messages suitable for feedback
81
+ */
82
+ function formatZodErrors(error: z.ZodError): string[] {
83
+ return error.issues.map((issue) => {
84
+ const path = issue.path.length > 0 ? `${issue.path.join(".")}: ` : "";
85
+ return `${path}${issue.message}`;
86
+ });
87
+ }
88
+
89
+ /**
90
+ * Schema registry for named schema lookups
91
+ */
92
+ const SCHEMA_REGISTRY: Record<string, ZodSchema> = {
93
+ evaluation: EvaluationSchema,
94
+ task_decomposition: TaskDecompositionSchema,
95
+ bead_tree: BeadTreeSchema,
96
+ };
97
+
98
+ /**
99
+ * Get schema by name from registry
100
+ */
101
+ function getSchemaByName(name: string): ZodSchema {
102
+ const schema = SCHEMA_REGISTRY[name];
103
+ if (!schema) {
104
+ throw new Error(
105
+ `Unknown schema: ${name}. Available: ${Object.keys(SCHEMA_REGISTRY).join(", ")}`,
106
+ );
107
+ }
108
+ return schema;
109
+ }
110
+
111
+ /**
112
+ * Try to extract JSON from text using multiple strategies
113
+ *
114
+ * @param text - Raw text that may contain JSON
115
+ * @returns Tuple of [parsed object, extraction method used]
116
+ * @throws JsonExtractionError if no JSON can be extracted
117
+ */
118
+ function extractJsonFromText(text: string): [unknown, string] {
119
+ const trimmed = text.trim();
120
+ const strategies: string[] = [];
121
+
122
+ // Strategy 1: Direct parse (entire string is valid JSON)
123
+ strategies.push("direct_parse");
124
+ try {
125
+ return [JSON.parse(trimmed), "direct_parse"];
126
+ } catch {
127
+ // Continue to other strategies
128
+ }
129
+
130
+ // Strategy 2: Extract from ```json code blocks
131
+ strategies.push("json_code_block");
132
+ const jsonBlockMatch = trimmed.match(/```json\s*([\s\S]*?)```/i);
133
+ if (jsonBlockMatch) {
134
+ try {
135
+ return [JSON.parse(jsonBlockMatch[1].trim()), "json_code_block"];
136
+ } catch {
137
+ // Continue to other strategies
138
+ }
139
+ }
140
+
141
+ // Strategy 3: Extract from any code block (```...```)
142
+ strategies.push("any_code_block");
143
+ const codeBlockMatch = trimmed.match(/```\s*([\s\S]*?)```/);
144
+ if (codeBlockMatch) {
145
+ try {
146
+ return [JSON.parse(codeBlockMatch[1].trim()), "any_code_block"];
147
+ } catch {
148
+ // Continue to other strategies
149
+ }
150
+ }
151
+
152
+ // Strategy 4: Find first balanced {...} object
153
+ strategies.push("brace_match_object");
154
+ const objectJson = findBalancedBraces(trimmed, "{", "}");
155
+ if (objectJson) {
156
+ try {
157
+ return [JSON.parse(objectJson), "brace_match_object"];
158
+ } catch {
159
+ // Continue to other strategies
160
+ }
161
+ }
162
+
163
+ // Strategy 5: Find first balanced [...] array
164
+ strategies.push("brace_match_array");
165
+ const arrayJson = findBalancedBraces(trimmed, "[", "]");
166
+ if (arrayJson) {
167
+ try {
168
+ return [JSON.parse(arrayJson), "brace_match_array"];
169
+ } catch {
170
+ // Continue to other strategies
171
+ }
172
+ }
173
+
174
+ // Strategy 6: Try to repair common JSON issues and parse
175
+ strategies.push("repair_json");
176
+ const repaired = attemptJsonRepair(trimmed);
177
+ if (repaired !== trimmed) {
178
+ try {
179
+ return [JSON.parse(repaired), "repair_json"];
180
+ } catch {
181
+ // All strategies failed
182
+ }
183
+ }
184
+
185
+ throw new JsonExtractionError(
186
+ "Could not extract valid JSON from response",
187
+ text,
188
+ strategies,
189
+ );
190
+ }
191
+
192
+ /**
193
+ * Find a balanced pair of braces/brackets
194
+ */
195
+ function findBalancedBraces(
196
+ text: string,
197
+ open: string,
198
+ close: string,
199
+ ): string | null {
200
+ const startIdx = text.indexOf(open);
201
+ if (startIdx === -1) return null;
202
+
203
+ let depth = 0;
204
+ let inString = false;
205
+ let escapeNext = false;
206
+
207
+ for (let i = startIdx; i < text.length; i++) {
208
+ const char = text[i];
209
+
210
+ if (escapeNext) {
211
+ escapeNext = false;
212
+ continue;
213
+ }
214
+
215
+ if (char === "\\") {
216
+ escapeNext = true;
217
+ continue;
218
+ }
219
+
220
+ if (char === '"') {
221
+ inString = !inString;
222
+ continue;
223
+ }
224
+
225
+ if (inString) continue;
226
+
227
+ if (char === open) {
228
+ depth++;
229
+ } else if (char === close) {
230
+ depth--;
231
+ if (depth === 0) {
232
+ return text.slice(startIdx, i + 1);
233
+ }
234
+ }
235
+ }
236
+
237
+ return null;
238
+ }
239
+
240
+ /**
241
+ * Attempt common JSON repairs
242
+ *
243
+ * Handles:
244
+ * - Trailing commas
245
+ * - Single quotes instead of double quotes
246
+ * - Unquoted keys
247
+ * - Newlines in strings
248
+ */
249
+ function attemptJsonRepair(text: string): string {
250
+ let repaired = text;
251
+
252
+ // Find JSON-like content first
253
+ const match = repaired.match(/[\[{][\s\S]*[\]}]/);
254
+ if (!match) return text;
255
+
256
+ repaired = match[0];
257
+
258
+ // Replace single quotes with double quotes (but not inside strings)
259
+ // This is a simple heuristic - won't work for all cases
260
+ repaired = repaired.replace(/(?<![\\])'([^']*)'(?=\s*:)/g, '"$1"');
261
+
262
+ // Remove trailing commas before } or ]
263
+ repaired = repaired.replace(/,(\s*[}\]])/g, "$1");
264
+
265
+ // Replace literal newlines in strings with \n
266
+ repaired = repaired.replace(
267
+ /"([^"]*)\n([^"]*)"/g,
268
+ (_, before, after) => `"${before}\\n${after}"`,
269
+ );
270
+
271
+ return repaired;
272
+ }
273
+
274
+ // ============================================================================
275
+ // Validation Result Types
276
+ // ============================================================================
277
+
278
+ /**
279
+ * Result of a structured validation attempt
280
+ */
281
+ interface StructuredValidationResult<T = unknown> {
282
+ success: boolean;
283
+ data?: T;
284
+ attempts: number;
285
+ errors?: string[];
286
+ extractionMethod?: string;
287
+ }
288
+
289
+ // ============================================================================
290
+ // Tool Definitions
291
+ // ============================================================================
292
+
293
+ /**
294
+ * Extract JSON from markdown/text response
295
+ *
296
+ * Tries multiple extraction strategies in order:
297
+ * 1. Direct JSON parse
298
+ * 2. ```json code blocks
299
+ * 3. Any code blocks
300
+ * 4. Brace matching for objects
301
+ * 5. Bracket matching for arrays
302
+ * 6. JSON repair attempts
303
+ */
304
+ export const structured_extract_json = tool({
305
+ description:
306
+ "Extract JSON from markdown/text response. Tries multiple strategies: direct parse, code blocks, brace matching, JSON repair.",
307
+ args: {
308
+ text: tool.schema.string().describe("Text containing JSON to extract"),
309
+ },
310
+ async execute(args, ctx) {
311
+ try {
312
+ const [parsed, method] = extractJsonFromText(args.text);
313
+ return JSON.stringify(
314
+ {
315
+ success: true,
316
+ data: parsed,
317
+ extraction_method: method,
318
+ },
319
+ null,
320
+ 2,
321
+ );
322
+ } catch (error) {
323
+ if (error instanceof JsonExtractionError) {
324
+ return JSON.stringify(
325
+ {
326
+ success: false,
327
+ error: error.message,
328
+ attempted_strategies: error.attemptedStrategies,
329
+ raw_input_preview: args.text.slice(0, 200),
330
+ },
331
+ null,
332
+ 2,
333
+ );
334
+ }
335
+ throw error;
336
+ }
337
+ },
338
+ });
339
+
340
+ /**
341
+ * Validate agent response against a named schema
342
+ *
343
+ * Extracts JSON from the response using multiple strategies,
344
+ * then validates against the specified schema.
345
+ */
346
+ export const structured_validate = tool({
347
+ description:
348
+ "Validate agent response against a schema. Extracts JSON and validates with Zod. Returns structured errors for retry feedback.",
349
+ args: {
350
+ response: tool.schema.string().describe("Agent response to validate"),
351
+ schema_name: tool.schema
352
+ .enum(["evaluation", "task_decomposition", "bead_tree"])
353
+ .describe("Schema to validate against"),
354
+ max_retries: tool.schema
355
+ .number()
356
+ .min(1)
357
+ .max(5)
358
+ .optional()
359
+ .describe("Max retries (for tracking - actual retry logic is external)"),
360
+ },
361
+ async execute(args, ctx) {
362
+ const maxRetries = args.max_retries ?? 3;
363
+ const result: ValidationResult = {
364
+ success: false,
365
+ attempts: 1,
366
+ errors: [],
367
+ };
368
+
369
+ // Step 1: Extract JSON
370
+ let extracted: unknown;
371
+ let extractionMethod: string;
372
+
373
+ try {
374
+ [extracted, extractionMethod] = extractJsonFromText(args.response);
375
+ result.extractionMethod = extractionMethod;
376
+ } catch (error) {
377
+ if (error instanceof JsonExtractionError) {
378
+ result.errors = [
379
+ `JSON extraction failed after trying: ${error.attemptedStrategies.join(", ")}`,
380
+ `Input preview: ${args.response.slice(0, 100)}...`,
381
+ ];
382
+ return JSON.stringify(result, null, 2);
383
+ }
384
+ throw error;
385
+ }
386
+
387
+ // Step 2: Validate against schema
388
+ try {
389
+ const schema = getSchemaByName(args.schema_name);
390
+ const validated = schema.parse(extracted);
391
+
392
+ result.success = true;
393
+ result.data = validated;
394
+ delete result.errors;
395
+
396
+ return JSON.stringify(result, null, 2);
397
+ } catch (error) {
398
+ if (error instanceof z.ZodError) {
399
+ const formatted = formatZodErrors(error);
400
+ result.errors = formatted;
401
+
402
+ // Add hint for retries
403
+ if (result.attempts < maxRetries) {
404
+ result.errors.push(
405
+ `\nFix these issues and try again (attempt ${result.attempts}/${maxRetries})`,
406
+ );
407
+ }
408
+
409
+ return JSON.stringify(result, null, 2);
410
+ }
411
+ throw error;
412
+ }
413
+ },
414
+ });
415
+
416
+ /**
417
+ * Parse and validate evaluation response from an agent
418
+ *
419
+ * Specialized tool for parsing self-evaluations. Returns
420
+ * the validated Evaluation or structured errors.
421
+ */
422
+ export const structured_parse_evaluation = tool({
423
+ description:
424
+ "Parse and validate evaluation response from an agent. Uses EvaluationSchema.",
425
+ args: {
426
+ response: tool.schema
427
+ .string()
428
+ .describe("Agent response containing evaluation"),
429
+ },
430
+ async execute(args, ctx) {
431
+ try {
432
+ const [extracted, method] = extractJsonFromText(args.response);
433
+ const validated = EvaluationSchema.parse(extracted) as Evaluation;
434
+
435
+ return JSON.stringify(
436
+ {
437
+ success: true,
438
+ data: validated,
439
+ extraction_method: method,
440
+ summary: {
441
+ passed: validated.passed,
442
+ criteria_count: Object.keys(validated.criteria).length,
443
+ failed_criteria: Object.entries(validated.criteria)
444
+ .filter(([_, v]) => !(v as { passed: boolean }).passed)
445
+ .map(([k]) => k),
446
+ },
447
+ },
448
+ null,
449
+ 2,
450
+ );
451
+ } catch (error) {
452
+ if (error instanceof JsonExtractionError) {
453
+ return JSON.stringify(
454
+ {
455
+ success: false,
456
+ error: "Failed to extract JSON from response",
457
+ details: error.message,
458
+ attempted_strategies: error.attemptedStrategies,
459
+ feedback: [
460
+ "- Response must contain valid JSON",
461
+ "- Use ```json code blocks for clarity",
462
+ "- Ensure all braces and brackets are balanced",
463
+ ].join("\n"),
464
+ },
465
+ null,
466
+ 2,
467
+ );
468
+ }
469
+
470
+ if (error instanceof z.ZodError) {
471
+ const bullets = formatZodErrors(error);
472
+ return JSON.stringify(
473
+ {
474
+ success: false,
475
+ error: "Evaluation does not match schema",
476
+ validation_errors: bullets,
477
+ feedback: bullets.map((e) => `- ${e}`).join("\n"),
478
+ expected_shape: {
479
+ passed: "boolean",
480
+ criteria: "Record<string, { passed: boolean, feedback: string }>",
481
+ overall_feedback: "string",
482
+ retry_suggestion: "string | null",
483
+ },
484
+ },
485
+ null,
486
+ 2,
487
+ );
488
+ }
489
+
490
+ throw error;
491
+ }
492
+ },
493
+ });
494
+
495
+ /**
496
+ * Parse and validate task decomposition response
497
+ *
498
+ * Specialized tool for parsing decomposition results.
499
+ * Validates the structure and returns file lists for reservations.
500
+ */
501
+ export const structured_parse_decomposition = tool({
502
+ description:
503
+ "Parse and validate task decomposition response. Uses TaskDecompositionSchema. Returns validated decomposition with file lists.",
504
+ args: {
505
+ response: tool.schema
506
+ .string()
507
+ .describe("Agent response containing decomposition"),
508
+ },
509
+ async execute(args, ctx) {
510
+ try {
511
+ const [extracted, method] = extractJsonFromText(args.response);
512
+ const validated = TaskDecompositionSchema.parse(
513
+ extracted,
514
+ ) as TaskDecomposition;
515
+
516
+ // Collect all files for reservation planning
517
+ const allFiles = validated.subtasks.flatMap((s) => s.files);
518
+ const uniqueFiles = [...new Set(allFiles)];
519
+
520
+ return JSON.stringify(
521
+ {
522
+ success: true,
523
+ data: validated,
524
+ extraction_method: method,
525
+ summary: {
526
+ task:
527
+ validated.task.slice(0, 50) +
528
+ (validated.task.length > 50 ? "..." : ""),
529
+ subtask_count: validated.subtasks.length,
530
+ dependency_count: validated.dependencies?.length ?? 0,
531
+ total_files: uniqueFiles.length,
532
+ files: uniqueFiles,
533
+ effort_breakdown: validated.subtasks.reduce(
534
+ (acc, s) => {
535
+ acc[s.estimated_effort] = (acc[s.estimated_effort] || 0) + 1;
536
+ return acc;
537
+ },
538
+ {} as Record<string, number>,
539
+ ),
540
+ },
541
+ },
542
+ null,
543
+ 2,
544
+ );
545
+ } catch (error) {
546
+ if (error instanceof JsonExtractionError) {
547
+ return JSON.stringify(
548
+ {
549
+ success: false,
550
+ error: "Failed to extract JSON from response",
551
+ details: error.message,
552
+ attempted_strategies: error.attemptedStrategies,
553
+ feedback: [
554
+ "- Response must contain valid JSON",
555
+ "- Use ```json code blocks for clarity",
556
+ "- Ensure all braces and brackets are balanced",
557
+ ].join("\n"),
558
+ },
559
+ null,
560
+ 2,
561
+ );
562
+ }
563
+
564
+ if (error instanceof z.ZodError) {
565
+ const bullets = formatZodErrors(error);
566
+ return JSON.stringify(
567
+ {
568
+ success: false,
569
+ error: "Decomposition does not match schema",
570
+ validation_errors: bullets,
571
+ feedback: bullets.map((e) => `- ${e}`).join("\n"),
572
+ expected_shape: {
573
+ task: "string (original task)",
574
+ reasoning: "string (optional)",
575
+ subtasks: [
576
+ {
577
+ title: "string",
578
+ description: "string",
579
+ files: ["string array of file paths"],
580
+ estimated_effort: "trivial | small | medium | large",
581
+ risks: ["optional string array"],
582
+ },
583
+ ],
584
+ dependencies: [
585
+ {
586
+ from: "number (subtask index)",
587
+ to: "number (subtask index)",
588
+ type: "blocks | requires | related",
589
+ },
590
+ ],
591
+ },
592
+ },
593
+ null,
594
+ 2,
595
+ );
596
+ }
597
+
598
+ throw error;
599
+ }
600
+ },
601
+ });
602
+
603
+ /**
604
+ * Parse and validate a bead tree (epic with subtasks)
605
+ *
606
+ * Validates the structure before creating beads.
607
+ */
608
+ export const structured_parse_bead_tree = tool({
609
+ description:
610
+ "Parse and validate bead tree response. Uses BeadTreeSchema. Validates before creating epic with subtasks.",
611
+ args: {
612
+ response: tool.schema
613
+ .string()
614
+ .describe("Agent response containing bead tree"),
615
+ },
616
+ async execute(args, ctx) {
617
+ try {
618
+ const [extracted, method] = extractJsonFromText(args.response);
619
+ const validated = BeadTreeSchema.parse(extracted) as BeadTree;
620
+
621
+ // Collect all files for reservation planning
622
+ const allFiles = validated.subtasks.flatMap((s) => s.files);
623
+ const uniqueFiles = [...new Set(allFiles)];
624
+
625
+ return JSON.stringify(
626
+ {
627
+ success: true,
628
+ data: validated,
629
+ extraction_method: method,
630
+ summary: {
631
+ epic_title: validated.epic.title,
632
+ subtask_count: validated.subtasks.length,
633
+ total_files: uniqueFiles.length,
634
+ files: uniqueFiles,
635
+ complexity_total: validated.subtasks.reduce(
636
+ (sum, s) => sum + s.estimated_complexity,
637
+ 0,
638
+ ),
639
+ },
640
+ },
641
+ null,
642
+ 2,
643
+ );
644
+ } catch (error) {
645
+ if (error instanceof JsonExtractionError) {
646
+ return JSON.stringify(
647
+ {
648
+ success: false,
649
+ error: "Failed to extract JSON from response",
650
+ details: error.message,
651
+ feedback: [
652
+ "- Response must contain valid JSON",
653
+ "- Use ```json code blocks for clarity",
654
+ ].join("\n"),
655
+ },
656
+ null,
657
+ 2,
658
+ );
659
+ }
660
+
661
+ if (error instanceof z.ZodError) {
662
+ const bullets = formatZodErrors(error);
663
+ return JSON.stringify(
664
+ {
665
+ success: false,
666
+ error: "Bead tree does not match schema",
667
+ validation_errors: bullets,
668
+ feedback: bullets.map((e) => `- ${e}`).join("\n"),
669
+ expected_shape: {
670
+ epic: { title: "string", description: "string (optional)" },
671
+ subtasks: [
672
+ {
673
+ title: "string",
674
+ description: "string (optional)",
675
+ files: ["string array"],
676
+ dependencies: ["number array of subtask indices"],
677
+ estimated_complexity: "1-5",
678
+ },
679
+ ],
680
+ },
681
+ },
682
+ null,
683
+ 2,
684
+ );
685
+ }
686
+
687
+ throw error;
688
+ }
689
+ },
690
+ });
691
+
692
+ // ============================================================================
693
+ // Utility Exports (for use by other modules)
694
+ // ============================================================================
695
+
696
+ export { extractJsonFromText, formatZodErrors, getSchemaByName };
697
+
698
+ // ============================================================================
699
+ // Tool Exports
700
+ // ============================================================================
701
+
702
+ export const structuredTools = {
703
+ "structured_extract_json": structured_extract_json,
704
+ "structured_validate": structured_validate,
705
+ "structured_parse_evaluation": structured_parse_evaluation,
706
+ "structured_parse_decomposition": structured_parse_decomposition,
707
+ "structured_parse_bead_tree": structured_parse_bead_tree,
708
+ };