@goodandready/dsh-moa 0.2.26 → 0.2.29

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,102 @@
1
+ /**
2
+ * Smart Preset Router for Mixture of Agents (MoA).
3
+ * Supports heuristic keyword classification and optional JEV/LLM zero-shot routing.
4
+ */
5
+
6
+ const INTENT_RULES = [
7
+ { preset: 'security-audit', regex: /\b(security|vulnerability|exploit|cve|xss|injection|sanitize|threat|auth|permission|secret)\b/i },
8
+ { preset: 'bug-hunter', regex: /\b(bug|fix|error|crash|exception|regression|reproduce|broken|fail|issue)\b/i },
9
+ { preset: 'refactor-cleanup', regex: /\b(refactor|clean|simplify|ponytail|yagni|prune|dedup|modular|dead\s*code)\b/i },
10
+ { preset: 'code-review', regex: /\b(review|critique|audit|pr|pull\s*request|diff|inspect|check\s*code)\b/i },
11
+ { preset: 'frontend-ui', regex: /\b(frontend|ui|css|html|react|vue|tailwind|styling|styles?|stylesheet|component|button|layout|page|landing|modal)\b/i },
12
+ { preset: 'deep-architect', regex: /\b(architect|distributed|system|microservice|database|scalab|pipeline|infra|schema)\b/i },
13
+ { preset: 'math-logic', regex: /\b(math|algorithm|proof|matrix|calc|complexity|combinatorics|graph|tree|dynamic\s*programming)\b/i },
14
+ { preset: 'creative-brainstorm', regex: /\b(brainstorm|idea|concept|creative|alternative|options|feature\s*idea)\b/i },
15
+ { preset: 'fast-audit', regex: /\b(quick|fast|brief|summary|one-line|short|tldr)\b/i },
16
+ ]
17
+
18
+ /**
19
+ * Fast keyword-based intent classification.
20
+ */
21
+ export function classifyPromptIntent(prompt = '') {
22
+ if (!prompt || typeof prompt !== 'string') return null
23
+ const text = prompt.trim()
24
+ for (const rule of INTENT_RULES) {
25
+ if (rule.regex.test(text)) {
26
+ return rule.preset
27
+ }
28
+ }
29
+ return null
30
+ }
31
+
32
+ /**
33
+ * Resolves the optimal preset for a given user prompt.
34
+ */
35
+ export async function resolvePresetForPrompt({
36
+ prompt = '',
37
+ presets = [],
38
+ defaultPreset = 'default',
39
+ routingModel = null,
40
+ callLlm = null,
41
+ enabled = false,
42
+ timeoutMs = 2000,
43
+ }) {
44
+ const availableNames = (presets || []).map((p) => p.name).filter(Boolean)
45
+ const safeDefault = availableNames.includes(defaultPreset) ? defaultPreset : (availableNames[0] || 'default')
46
+
47
+ if (!enabled || !prompt || typeof prompt !== 'string') {
48
+ return { presetName: safeDefault, reason: 'default', isAutoRouted: false }
49
+ }
50
+
51
+ // 1. If LLM routing model (e.g. JEV) is configured and callable, run fast zero-shot classifier
52
+ if (routingModel?.provider && routingModel?.model && typeof callLlm === 'function') {
53
+ try {
54
+ const systemPrompt = `You are the MoA Preset Router. Given user prompt, classify which preset is best suited.
55
+ Available presets: ${availableNames.join(', ')}
56
+ Respond with ONLY the chosen preset name and nothing else.`
57
+
58
+ const abortCtrl = new AbortController()
59
+ const timer = setTimeout(() => abortCtrl.abort(), timeoutMs)
60
+ timer.unref?.()
61
+
62
+ const res = await callLlm({
63
+ provider: routingModel.provider,
64
+ model: routingModel.model,
65
+ messages: [
66
+ { role: 'system', content: systemPrompt },
67
+ { role: 'user', content: prompt.slice(0, 500) },
68
+ ],
69
+ temperature: 0.1,
70
+ maxTokens: 30,
71
+ signal: abortCtrl.signal,
72
+ })
73
+ clearTimeout(timer)
74
+
75
+ const rawChoice = typeof res === 'string' ? res : (res?.content || res?.text || '')
76
+ const match = availableNames.find((name) => new RegExp(`\\b${name}\\b`, 'i').test(rawChoice))
77
+ if (match) {
78
+ return {
79
+ presetName: match,
80
+ reason: 'llm_classifier',
81
+ routerModel: `${routingModel.provider}:${routingModel.model}`,
82
+ isAutoRouted: true,
83
+ }
84
+ }
85
+ } catch {
86
+ // Fallback silently to heuristic rules on timeout or network error
87
+ }
88
+ }
89
+
90
+ // 2. Keyword heuristic classifier
91
+ const heuristic = classifyPromptIntent(prompt)
92
+ if (heuristic && availableNames.includes(heuristic)) {
93
+ return {
94
+ presetName: heuristic,
95
+ reason: 'keyword_heuristic',
96
+ isAutoRouted: true,
97
+ }
98
+ }
99
+
100
+ // 3. Fallback to default
101
+ return { presetName: safeDefault, reason: 'default_fallback', isAutoRouted: false }
102
+ }
package/lib/moa-runner.js CHANGED
@@ -1,14 +1,19 @@
1
+ import { logger } from './logger.js'
1
2
  import path from 'node:path'
2
3
  import crypto from 'node:crypto'
3
4
  import { bestEffort } from './best-effort.js'
4
5
  import { extractFileBlocks, collectProjectContext, formatProjectContext, isRefinementTask, writeCandidateWorkspace, promoteCandidateWorkspace, cleanMoaWorkspaces, verifyFileSyntax } from './file-workspace.js'
5
6
  import { estimateTokenCost, summarizeMoAUsage } from './pricing.js'
6
- import { recordMoaRun, recordMoaRunAsync, candidatesForHistory } from './history.js'
7
+ import { recordMoaRunAsync, candidatesForHistory } from './history.js'
7
8
  import { createPromotedPreview } from './live-canvas.js'
8
9
  import { slotLabel, cleanAdvisoryMessages, isBroadPromptRequiringQuestions, buildQuestionSynthesisPrompt, buildCuratorSynthesisPrompt, buildSynthesisPrompt, buildPeerCritiquePrompt, ROLE_PERSONA_PROMPTS, SYSTEM_ROLE_PROPOSER, ANTIPATTERNS_RUBRIC } from './moa-prompts.js'
9
10
  import { parseWinnerIndex, parseRecommendedAssembler, parseMoACommand, stripOrSummarizeCode, formatMoAResponse } from './moa-parser.js'
10
11
  import { REFERENCE_SYSTEM_PROMPT, callWithTransientRetry, runReferencesParallel } from './moa-candidates.js'
11
12
  import { executeTestGateForCandidates, runCandidateTestGate } from './moa-test-gate.js'
13
+ import { executeMultiJudgePanel, buildCompositeBlockDirectives } from './moa-multi-judge.js'
14
+ import { applyBudgetGuardrails } from './moa-budget.js'
15
+ import { extractPriorTurnBaseline, pruneMultiTurnMessages } from './moa-context.js'
16
+ import { generateMoABenchmarkReport } from './moa-report.js'
12
17
 
13
18
  // Re-exports for consumers & backward compatibility
14
19
  export { slotLabel, cleanAdvisoryMessages, isBroadPromptRequiringQuestions, buildQuestionSynthesisPrompt, buildCuratorSynthesisPrompt, buildSynthesisPrompt, ANTIPATTERNS_RUBRIC } from './moa-prompts.js'
@@ -16,6 +21,7 @@ export { parseWinnerIndex, parseRecommendedAssembler, parseMoACommand, stripOrSu
16
21
  export { estimateTokenCost, summarizeMoAUsage } from './pricing.js'
17
22
  export { REFERENCE_SYSTEM_PROMPT, callWithTransientRetry, runReferencesParallel } from './moa-candidates.js'
18
23
  export { executeTestGateForCandidates, runCandidateTestGate } from './moa-test-gate.js'
24
+ export { streamMoATurn } from './moa-stream.js'
19
25
 
20
26
  /**
21
27
  * Executes the full Mixture of Agents pipeline.
@@ -60,6 +66,38 @@ export async function runMoAPipeline({
60
66
  const isBlindEvaluation = Boolean(preset?.blind_evaluation), isPeerCritiqueEnabled = Boolean(preset?.peer_critique_enabled)
61
67
  const allowCandidateOverride = Boolean(preset?.allow_candidate_override), runId = crypto.randomUUID()
62
68
 
69
+ // Feature 6: Budget Guardrail check
70
+ const budgetCheck = applyBudgetGuardrails({
71
+ references: referenceModels,
72
+ enabled: preset?.budget_guard_enabled,
73
+ maxBudgetUsd: preset?.max_budget_usd,
74
+ action: preset?.budget_action || 'trim',
75
+ prices,
76
+ promptLength: userPrompt?.length || 1000,
77
+ })
78
+ if (!budgetCheck.allowed) {
79
+ return {
80
+ kind: 'failure',
81
+ content: `🛑 Budget Guardrail Abort: ${budgetCheck.reason}`,
82
+ aggregator: slotLabel(primaryJudge),
83
+ references: [],
84
+ presetName: preset?.name || 'default',
85
+ isRefinement: false,
86
+ isFastMode,
87
+ winningIndex: 0,
88
+ winnerModel: 'none',
89
+ promotedFiles: [],
90
+ usage: { totalTokens: 0, totalCostUsd: 0, candidates: [], aggregator: { totalTokens: 0, costUsd: 0 } },
91
+ skippedFiles: 0,
92
+ skippedList: [],
93
+ durationMs: Date.now() - startTime,
94
+ }
95
+ }
96
+ const effectiveReferences = budgetCheck.references
97
+ if (budgetCheck.action === 'trim' && typeof onProgress === 'function') {
98
+ onProgress(`✂️ *${budgetCheck.reason}*\n`)
99
+ }
100
+
63
101
  // 2. Collect project context for refinement tasks
64
102
  const collectedCtx = await collectProjectContext(cwd, 16000)
65
103
  const isRefinement = Boolean(collectedCtx?.files?.length > 0 && isRefinementTask(userPrompt, collectedCtx.files))
@@ -79,23 +117,28 @@ export async function runMoAPipeline({
79
117
  })
80
118
  }
81
119
 
82
- // 3. Build prompts & evaluate broad questionnaire needs
120
+ // 3. Build prompts & Feature 8 multi-turn context
121
+ const priorTurnBaseline = preset?.multi_turn_enabled !== false ? extractPriorTurnBaseline(messages) : null
122
+ const prunedHistory = preset?.multi_turn_enabled !== false ? pruneMultiTurnMessages(messages) : messages
123
+
83
124
  const candidateSystemPrompt = projectContext
84
125
  ? `${REFERENCE_SYSTEM_PROMPT}\n\n### Current Project Files & Context:\n${projectContext}`
85
126
  : REFERENCE_SYSTEM_PROMPT
86
127
 
87
128
  const askClarifyingQuestions = preset?.ask_clarifying_questions !== false
88
129
  const needsQuestions = askClarifyingQuestions && isBroadPromptRequiringQuestions(userPrompt, messages)
130
+ const enrichedMessages = [...prunedHistory, { role: 'user', content: userPrompt }]
89
131
 
90
- const enrichedMessages = [...messages, { role: 'user', content: userPrompt }]
91
-
92
- // 4. Parallel fan-out to candidate models with quorum & transient retry
132
+ // 4. Parallel fan-out to candidate models with temperature gradient & local fallback
93
133
  const referenceOutputs = await runReferencesParallel(
94
- referenceModels,
134
+ effectiveReferences,
95
135
  enrichedMessages,
96
136
  {
97
137
  systemPrompt: candidateSystemPrompt,
98
138
  temperature: refTemp,
139
+ temperature_gradient_enabled: Boolean(preset?.temperature_gradient_enabled),
140
+ local_fallback_enabled: Boolean(preset?.local_fallback_enabled),
141
+ local_fallback_models: preset?.local_fallback_models || [],
99
142
  maxTokens,
100
143
  prices,
101
144
  timeoutSec: refTimeoutSec,
@@ -185,7 +228,7 @@ export async function runMoAPipeline({
185
228
  }
186
229
  qUsage = estimateTokenCost(primaryJudge, qFallbackUsage, prices)
187
230
  } catch (err) {
188
- console.warn('[dsh-moa] Questionnaire synthesis failed, proceeding with fallback questions:', err)
231
+ logger.warn('[dsh-moa] Questionnaire synthesis failed, proceeding with fallback questions:', err)
189
232
  questionsContent = `### Clarification of Requirements: "${userPrompt}"\n\n1. **Architecture & Scope**: Single-file deliverable or multi-module project structure?\n2. **Design & Style**: Minimalist, dark mode, or clean neutral theme?\n3. **Functional Priorities**: Core MVP or comprehensive extended implementation?\n\n*Reply with your preferences (e.g. "1, 2") or proceed with defaults.*`
190
233
  }
191
234
 
@@ -195,7 +238,7 @@ export async function runMoAPipeline({
195
238
  const durationMs = Date.now() - startTime
196
239
 
197
240
  try {
198
- recordMoaRun({
241
+ await recordMoaRunAsync({
199
242
  prompt: userPrompt,
200
243
  preset: preset?.name || 'default',
201
244
  isRefinement,
@@ -209,7 +252,7 @@ export async function runMoAPipeline({
209
252
  durationMs,
210
253
  }, historyFilePath || undefined)
211
254
  } catch (histErr) {
212
- console.warn('[dsh-moa] Failed to record questionnaire run in history:', histErr?.message || histErr)
255
+ logger.warn('[dsh-moa] Failed to record questionnaire run in history:', histErr?.message || histErr)
213
256
  }
214
257
 
215
258
  return {
@@ -251,7 +294,7 @@ export async function runMoAPipeline({
251
294
  const durationMs = Date.now() - startTime
252
295
 
253
296
  try {
254
- recordMoaRun({
297
+ await recordMoaRunAsync({
255
298
  prompt: userPrompt,
256
299
  preset: preset?.name || 'default',
257
300
  isRefinement,
@@ -266,7 +309,7 @@ export async function runMoAPipeline({
266
309
  durationMs,
267
310
  }, historyFilePath || undefined)
268
311
  } catch (histErr) {
269
- console.warn('[dsh-moa] Failed to record fast synthesis run in history:', histErr?.message || histErr)
312
+ logger.warn('[dsh-moa] Failed to record fast synthesis run in history:', histErr?.message || histErr)
270
313
  }
271
314
 
272
315
  return {
@@ -334,7 +377,7 @@ export async function runMoAPipeline({
334
377
  cand.costUsd = Number(((cand.costUsd || 0) + extraCost.costUsd).toFixed(5))
335
378
  }
336
379
  } catch (r2CostErr) {
337
- console.warn('[dsh-moa] Failed to estimate Round 2 cost:', r2CostErr?.message || r2CostErr)
380
+ logger.warn('[dsh-moa] Failed to estimate Round 2 cost:', r2CostErr?.message || r2CostErr)
338
381
  }
339
382
  })
340
383
  await Promise.allSettled(r2Promises)
@@ -347,10 +390,32 @@ export async function runMoAPipeline({
347
390
  })
348
391
  }
349
392
 
393
+ // 7c. Feature 2: Multi-Judge Panel & Consensus Voting
394
+ let multiJudgeResult = null
395
+ if (preset?.multi_judge_enabled && successfulRefs.length > 1) {
396
+ multiJudgeResult = await executeMultiJudgePanel({
397
+ judges: (preset.judge_models && preset.judge_models.length > 0) ? preset.judge_models : [primaryJudge],
398
+ candidates: successfulRefs,
399
+ userPrompt,
400
+ callLlm,
401
+ strategy: preset.judge_voting_strategy || 'majority',
402
+ signal,
403
+ onProgress,
404
+ })
405
+ }
406
+
407
+ // 7d. Feature 3: Composite Block Merge Directive
408
+ const compositeMergeDirective = (preset?.composite_merge_enabled && successfulRefs.length > 1)
409
+ ? buildCompositeBlockDirectives(successfulRefs)
410
+ : null
411
+
350
412
  // 8. Synthesis phase via primary judge or fallback chain
351
413
  const synthesisPrompt = buildSynthesisPrompt(userPrompt, referenceOutputs, judgeCriteria, {
352
414
  curatorSynthesis: isCuratorSynthesis,
353
415
  blindEvaluation: isBlindEvaluation,
416
+ priorTurnBaseline,
417
+ compositeMergeDirective,
418
+ consensusReport: multiJudgeResult?.consensusReport,
354
419
  })
355
420
  let synthesizedText = ''
356
421
  let aggUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 }
@@ -396,7 +461,7 @@ export async function runMoAPipeline({
396
461
  break
397
462
  } catch (err) {
398
463
  lastJudgeError = err
399
- console.warn(`[dsh-moa] Judge ${currentLabel} failed:`, err)
464
+ logger.warn(`[dsh-moa] Judge ${currentLabel} failed:`, err)
400
465
  if (typeof onProgress === 'function') {
401
466
  const nextJudge = judgesChain[jIdx + 1]
402
467
  const nextHint = nextJudge ? ` Trying fallback ${slotLabel(nextJudge)}...` : ''
@@ -412,7 +477,10 @@ export async function runMoAPipeline({
412
477
  }
413
478
 
414
479
  // 9. Evaluate winner & promote files
415
- const winningIndex = parseWinnerIndex(synthesizedText, 1, referenceOutputs.length)
480
+ const winningIndex = (multiJudgeResult?.consensus && multiJudgeResult?.winningCandidateIndex)
481
+ ? multiJudgeResult.winningCandidateIndex
482
+ : parseWinnerIndex(synthesizedText, 1, referenceOutputs.length)
483
+
416
484
  const recommendedAssembler = isCuratorSynthesis
417
485
  ? parseRecommendedAssembler(synthesizedText, winningIndex, referenceOutputs.length)
418
486
  : null
@@ -435,15 +503,34 @@ export async function runMoAPipeline({
435
503
  }
436
504
 
437
505
  const livePreview = await createPromotedPreview(liveCanvas, cwd, promotedFiles, referenceOutputs)
438
-
439
506
  const durationMs = Date.now() - startTime
440
507
  const usageSummary = summarizeMoAUsage(referenceOutputs, aggUsage)
441
508
  const winningRef = referenceOutputs[winningIndex - 1]
442
509
  const winnerModel = winningRef?.label || slotLabel(referenceModels[0])
443
510
  const finalAggLabel = slotLabel(chosenJudge)
444
511
 
512
+ // Feature 9: Benchmark & Post-Mortem PR report
513
+ let benchmarkReport = null
514
+ if (preset?.report_generation_enabled) {
515
+ benchmarkReport = generateMoABenchmarkReport({
516
+ runId,
517
+ timestamp: new Date().toISOString(),
518
+ preset: preset?.name || 'default',
519
+ prompt: userPrompt,
520
+ durationMs,
521
+ usage: usageSummary,
522
+ costUsd: usageSummary.totalCostUsd,
523
+ candidates: referenceOutputs,
524
+ winningIndex,
525
+ winningLabel: winnerModel,
526
+ consensus: multiJudgeResult,
527
+ testGate: referenceOutputs.find((r) => r.testResult)?.testResult || null,
528
+ isComposite: Boolean(preset?.composite_merge_enabled),
529
+ })
530
+ }
531
+
445
532
  try {
446
- recordMoaRun({
533
+ await recordMoaRunAsync({
447
534
  id: runId,
448
535
  prompt: userPrompt,
449
536
  preset: preset?.name || 'default',
@@ -453,12 +540,14 @@ export async function runMoAPipeline({
453
540
  winnerIndex: winningIndex,
454
541
  winnerModel,
455
542
  promotedFiles,
543
+ benchmarkReport,
544
+ consensus: multiJudgeResult,
456
545
  totalTokens: usageSummary.totalTokens,
457
546
  totalCostUsd: usageSummary.totalCostUsd,
458
547
  durationMs,
459
548
  }, historyFilePath || undefined)
460
549
  } catch (histErr) {
461
- console.warn('[dsh-moa] Failed to record run in history:', histErr)
550
+ logger.warn('[dsh-moa] Failed to record run in history:', histErr)
462
551
  }
463
552
 
464
553
  return {
@@ -476,6 +565,8 @@ export async function runMoAPipeline({
476
565
  promotedFiles,
477
566
  runId,
478
567
  allowCandidateOverride,
568
+ benchmarkReport,
569
+ consensus: multiJudgeResult,
479
570
  ...(livePreview ? { liveCanvas: livePreview } : {}),
480
571
  usage: usageSummary,
481
572
  skippedFiles: collectedCtx?.skippedFiles || 0,
@@ -483,113 +574,3 @@ export async function runMoAPipeline({
483
574
  durationMs,
484
575
  }
485
576
  }
486
-
487
- /**
488
- * Streams a full MoA turn into chat with live aggregator tokens and progress feedback.
489
- */
490
- export async function* streamMoATurn({ targetPreset, userPrompt, messages, callLlm, cwd, prices, historyFilePath, liveCanvas }, options = {}) {
491
- const signal = options?.signal
492
- if (signal?.aborted) return
493
-
494
- yield { type: 'block-start', index: 0, blockType: 'text' }
495
- yield { type: 'text-delta', index: 0, text: '🧠 *Mixture of Agents started...*\n\n' }
496
-
497
- // Async push-queue for zero-latency live delta streaming
498
- const queue = []
499
- let notify = null
500
-
501
- const pushUpdate = (text) => {
502
- queue.push({ type: 'text-delta', index: 0, text })
503
- if (notify) {
504
- notify()
505
- notify = null
506
- }
507
- }
508
-
509
- let done = false
510
- const pipelinePromise = runMoAPipeline({
511
- userPrompt,
512
- messages,
513
- preset: targetPreset,
514
- callLlm,
515
- cwd,
516
- onProgress: pushUpdate,
517
- onStreamDelta: (delta) => pushUpdate(delta),
518
- prices,
519
- historyFilePath,
520
- liveCanvas,
521
- signal,
522
- })
523
- .catch((err) => ({ error: err }))
524
- .finally(() => {
525
- done = true
526
- if (notify) {
527
- notify()
528
- notify = null
529
- }
530
- })
531
-
532
- const startTime = Date.now()
533
- let lastYieldTime = Date.now()
534
-
535
- try {
536
- while (!done || queue.length > 0) {
537
- if (signal?.aborted) {
538
- await cleanMoaWorkspaces(cwd)
539
- return
540
- }
541
-
542
- while (queue.length > 0) {
543
- const item = queue.shift()
544
- yield item
545
- lastYieldTime = Date.now()
546
- }
547
-
548
- if (done) break
549
-
550
- // Wait for next push item or max 2.5s heartbeat
551
- await Promise.race([
552
- new Promise((resolve) => { notify = resolve }),
553
- new Promise((resolve) => { const t = setTimeout(resolve, 2500); t.unref?.(); }),
554
- ])
555
-
556
- const elapsedSec = Math.floor((Date.now() - startTime) / 1000)
557
- if (!done && Date.now() - lastYieldTime >= 3000) {
558
- yield { type: 'text-delta', index: 0, text: `⏳ *[${elapsedSec}s] Still processing...*\n` }
559
- lastYieldTime = Date.now()
560
- }
561
- }
562
-
563
- const result = await pipelinePromise
564
- if (signal?.aborted) {
565
- await cleanMoaWorkspaces(cwd)
566
- return
567
- }
568
-
569
- if (result?.error) {
570
- const errText = '\n\n⚠️ **Mixture of Agents error**: ' + (result.error?.message || String(result.error))
571
- yield { type: 'text-delta', index: 0, text: errText }
572
- yield { type: 'block-end', index: 0, block: { type: 'text', text: errText } }
573
- yield { type: 'finish', reason: { kind: 'stop' } }
574
- return
575
- }
576
-
577
- const formatted = formatMoAResponse({
578
- moaResult: result,
579
- presetName: targetPreset?.name || 'default',
580
- })
581
-
582
- yield { type: 'text-delta', index: 0, text: '\n---\n\n' + formatted }
583
- yield { type: 'block-end', index: 0, block: { type: 'text', text: formatted } }
584
- yield {
585
- type: 'usage',
586
- usage: {
587
- inputTokens: result?.usage?.totalTokens || 0,
588
- outputTokens: Math.round((formatted.length || 0) / 4),
589
- },
590
- }
591
- yield { type: 'finish', reason: { kind: 'stop' } }
592
- } finally {
593
- // cleanup
594
- }
595
- }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Zero-latency Live Streaming Generator for MoA Turns
3
+ */
4
+
5
+ import { cleanMoaWorkspaces } from './file-workspace.js'
6
+ import { formatMoAResponse } from './moa-parser.js'
7
+ import { runMoAPipeline } from './moa-runner.js'
8
+
9
+ /**
10
+ * Streams a full MoA turn into chat with live aggregator tokens and progress feedback.
11
+ */
12
+ export async function* streamMoATurn({ targetPreset, userPrompt, messages, callLlm, cwd, prices, historyFilePath, liveCanvas }, options = {}) {
13
+ const signal = options?.signal
14
+ if (signal?.aborted) return
15
+
16
+ yield { type: 'block-start', index: 0, blockType: 'text' }
17
+ yield { type: 'text-delta', index: 0, text: '🧠 *Mixture of Agents started...*\n\n' }
18
+
19
+ // Async push-queue for zero-latency live delta streaming
20
+ const queue = []
21
+ let notify = null
22
+
23
+ const pushUpdate = (text) => {
24
+ queue.push({ type: 'text-delta', index: 0, text })
25
+ if (notify) {
26
+ notify()
27
+ notify = null
28
+ }
29
+ }
30
+
31
+ let done = false
32
+ const pipelinePromise = runMoAPipeline({
33
+ userPrompt,
34
+ messages,
35
+ preset: targetPreset,
36
+ callLlm,
37
+ cwd,
38
+ onProgress: pushUpdate,
39
+ onStreamDelta: (delta) => pushUpdate(delta),
40
+ prices,
41
+ historyFilePath,
42
+ liveCanvas,
43
+ signal,
44
+ })
45
+ .catch((err) => ({ error: err }))
46
+ .finally(() => {
47
+ done = true
48
+ if (notify) {
49
+ notify()
50
+ notify = null
51
+ }
52
+ })
53
+
54
+ const startTime = Date.now()
55
+ let lastYieldTime = Date.now()
56
+
57
+ try {
58
+ while (!done || queue.length > 0) {
59
+ if (signal?.aborted) {
60
+ await cleanMoaWorkspaces(cwd)
61
+ return
62
+ }
63
+
64
+ while (queue.length > 0) {
65
+ const item = queue.shift()
66
+ yield item
67
+ lastYieldTime = Date.now()
68
+ }
69
+
70
+ if (done) break
71
+
72
+ // Wait for next push item or max 2.5s heartbeat
73
+ await Promise.race([
74
+ new Promise((resolve) => { notify = resolve }),
75
+ new Promise((resolve) => { const t = setTimeout(resolve, 2500); t.unref?.(); }),
76
+ ])
77
+
78
+ const elapsedSec = Math.floor((Date.now() - startTime) / 1000)
79
+ if (!done && Date.now() - lastYieldTime >= 3000) {
80
+ yield { type: 'text-delta', index: 0, text: `⏳ *[${elapsedSec}s] Still processing...*\n` }
81
+ lastYieldTime = Date.now()
82
+ }
83
+ }
84
+
85
+ const result = await pipelinePromise
86
+ if (signal?.aborted) {
87
+ await cleanMoaWorkspaces(cwd)
88
+ return
89
+ }
90
+
91
+ if (result?.error) {
92
+ const errText = '\n\n⚠️ **Mixture of Agents error**: ' + (result.error?.message || String(result.error))
93
+ yield { type: 'text-delta', index: 0, text: errText }
94
+ yield { type: 'block-end', index: 0, block: { type: 'text', text: errText } }
95
+ yield { type: 'finish', reason: { kind: 'stop' } }
96
+ return
97
+ }
98
+
99
+ const formatted = formatMoAResponse({
100
+ moaResult: result,
101
+ presetName: targetPreset?.name || 'default',
102
+ })
103
+
104
+ yield { type: 'text-delta', index: 0, text: '\n---\n\n' + formatted }
105
+ yield { type: 'block-end', index: 0, block: { type: 'text', text: formatted } }
106
+ yield {
107
+ type: 'usage',
108
+ usage: {
109
+ inputTokens: result?.usage?.totalTokens || 0,
110
+ outputTokens: Math.round((formatted.length || 0) / 4),
111
+ },
112
+ }
113
+ yield { type: 'finish', reason: { kind: 'stop' } }
114
+ } finally {
115
+ // cleanup
116
+ }
117
+ }
@@ -4,6 +4,7 @@ import fsSync from 'node:fs'
4
4
  import path from 'node:path'
5
5
  import { spawn } from 'node:child_process'
6
6
  import { assertPathContained } from './file-workspace.js'
7
+ import { bestEffort } from './best-effort.js'
7
8
 
8
9
  /**
9
10
  * Resolves the directory for candidate sandbox.
@@ -179,9 +180,9 @@ export async function runCandidateTestGate({
179
180
  }
180
181
  } finally {
181
182
  if (stageDir) {
182
- try {
183
- await fs.rm(stageDir, { recursive: true, force: true })
184
- } catch {}
183
+ await bestEffort('moa-test-gate cleanup stageDir', () =>
184
+ fs.rm(stageDir, { recursive: true, force: true })
185
+ )
185
186
  }
186
187
  }
187
188
  }