@goodandready/dsh-moa 0.2.7 → 0.2.9
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/docs/design/DESIGN.md +35 -41
- package/lib/client.js +505 -459
- package/lib/file-workspace.js +1 -1
- package/lib/index.js +37 -5
- package/lib/moa-runner.js +300 -290
- package/lib/pricing.js +17 -3
- package/package.json +1 -1
package/lib/moa-runner.js
CHANGED
|
@@ -1,84 +1,58 @@
|
|
|
1
|
-
import { estimateTokenCost as calculateTokenCost, resolveModelRates, refreshCatalogInBackground, DIRECT_VENDOR_RATES, FALLBACK_RATES } from './pricing.js'
|
|
2
1
|
/**
|
|
3
|
-
* Mixture of Agents (MoA)
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* Mixture of Agents (MoA) Orchestrator Engine (#1 - #16)
|
|
3
|
+
*
|
|
4
|
+
* Implements:
|
|
5
|
+
* 1. Parallel candidate proposers with isolated workspaces (.moa/candidate-X/)
|
|
6
|
+
* 2. Real-time multi-file block extractor and project context collector
|
|
7
|
+
* 3. Iterative modification (Refinement) vs greenfield project awareness
|
|
8
|
+
* 4. Aggregator / Judge synthesis prompt and candidate winner evaluation
|
|
9
|
+
* 5. Automatic promotion of winning candidate files to workspace root
|
|
10
|
+
* 6. Native Live Canvas visual preview generation for interactive apps
|
|
11
|
+
* 7. Real-time cost tracking, token metrics and history logging
|
|
12
|
+
* 8. Zero-latency async push-queue streaming for llm/stream turns
|
|
6
13
|
*/
|
|
7
14
|
|
|
8
15
|
import {
|
|
9
16
|
extractFileBlocks,
|
|
17
|
+
collectProjectContext,
|
|
18
|
+
isRefinementTask,
|
|
10
19
|
writeCandidateWorkspace,
|
|
11
20
|
promoteCandidateWorkspace,
|
|
12
21
|
cleanMoaWorkspaces,
|
|
13
|
-
collectProjectContext,
|
|
14
|
-
isRefinementTask,
|
|
15
22
|
} from './file-workspace.js'
|
|
16
23
|
|
|
24
|
+
import { estimateTokenCost } from './pricing.js'
|
|
17
25
|
import { recordMoaRun } from './history.js'
|
|
18
26
|
|
|
19
|
-
export {
|
|
20
|
-
extractFileBlocks,
|
|
21
|
-
writeCandidateWorkspace,
|
|
22
|
-
promoteCandidateWorkspace,
|
|
23
|
-
cleanMoaWorkspaces,
|
|
24
|
-
collectProjectContext,
|
|
25
|
-
isRefinementTask,
|
|
26
|
-
}
|
|
27
|
+
export { estimateTokenCost } from './pricing.js'
|
|
27
28
|
|
|
28
|
-
export const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
...
|
|
41
|
-
\`\`\`
|
|
42
|
-
4. If the user request is broad, make opinionated, high-quality technical decisions and deliver a complete working project.
|
|
43
|
-
5. Never leave placeholders like "// TODO" or "... rest of code". Deliver exhaustive, working code.`
|
|
44
|
-
|
|
45
|
-
export const REFINEMENT_SYSTEM_PROMPT = `You are an expert software engineer performing an iterative modification / refinement on an existing project.
|
|
46
|
-
You are provided with the current codebase files and the user's delta request.
|
|
47
|
-
|
|
48
|
-
RULES:
|
|
49
|
-
1. Modify the existing files or create new files to fulfill the user's change request.
|
|
50
|
-
2. Deliver complete, production-ready updated code for every modified file.
|
|
51
|
-
3. Format each updated file in markdown code blocks with explicit file attribute:
|
|
52
|
-
\`\`\`javascript file="src/app.js"
|
|
53
|
-
...
|
|
54
|
-
\`\`\`
|
|
55
|
-
4. Keep the existing architecture, styling conventions and dependencies consistent.`
|
|
56
|
-
|
|
57
|
-
export const ADVISOR_QUESTION_PROMPT = `You are a senior technical advisor in a Mixture of Agents (MoA) architecture.
|
|
58
|
-
The user has provided a prompt that may have multiple design choices, architectural paths, or underspecified requirements.
|
|
59
|
-
|
|
60
|
-
Review the user prompt and identify 1 to 3 critical, high-impact clarifying questions or architectural options that would define the implementation (e.g. framework/vanilla, features, design style, target environment).
|
|
61
|
-
Keep questions very clear, structured, and actionable. Avoid trivial questions. Respond directly in Russian.`
|
|
62
|
-
|
|
63
|
-
export { resolveModelRates, refreshCatalogInBackground, DIRECT_VENDOR_RATES, FALLBACK_RATES }
|
|
64
|
-
|
|
65
|
-
export function estimateTokenCost(slot, usage = {}, customPrices = {}) {
|
|
66
|
-
return calculateTokenCost(slot, usage, customPrices)
|
|
67
|
-
}
|
|
29
|
+
export const DEFAULT_PROMPT = 'Please propose an optimal, well-structured, production-ready solution with full code and explanations.'
|
|
30
|
+
|
|
31
|
+
export const REFERENCE_SYSTEM_PROMPT = `You are an expert AI software architect and senior engineer acting as a candidate proposer in a Mixture of Agents (MoA) ensemble.
|
|
32
|
+
Your task is to provide the highest-quality, robust, complete, production-ready solution to the user request.
|
|
33
|
+
Write clean, modern, fully functional code without placeholders or shortcuts.
|
|
34
|
+
When generating files for a project, explicitly specify file paths using fenced code blocks with file annotations, e.g.:
|
|
35
|
+
\`\`\`html file="index.html"
|
|
36
|
+
\`\`\`
|
|
37
|
+
\`\`\`javascript file="script.js"
|
|
38
|
+
\`\`\`
|
|
39
|
+
\`\`\`css file="style.css"
|
|
40
|
+
\`\`\``
|
|
68
41
|
|
|
69
42
|
export function slotLabel(slot) {
|
|
70
|
-
if (!slot
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
return prov || model || 'unknown'
|
|
43
|
+
if (!slot) return 'unknown'
|
|
44
|
+
if (typeof slot === 'string') return slot
|
|
45
|
+
if (slot.provider && slot.model) return `${slot.provider}:${slot.model}`
|
|
46
|
+
return slot.model || slot.provider || 'unknown'
|
|
75
47
|
}
|
|
76
48
|
|
|
77
49
|
/**
|
|
78
|
-
*
|
|
79
|
-
*
|
|
50
|
+
* Extracts and sanitizes conversation history for candidate models.
|
|
51
|
+
* Robust against complex DSH message content types (strings, part arrays, objects, tool calls).
|
|
80
52
|
*/
|
|
81
|
-
export function cleanAdvisoryMessages(messages = [], maxCharBudget =
|
|
53
|
+
export function cleanAdvisoryMessages(messages = [], maxCharBudget = 24000) {
|
|
54
|
+
if (!Array.isArray(messages)) return []
|
|
55
|
+
|
|
82
56
|
const trimmed = []
|
|
83
57
|
for (const msg of messages) {
|
|
84
58
|
if (!msg || typeof msg !== 'object') continue
|
|
@@ -90,12 +64,21 @@ export function cleanAdvisoryMessages(messages = [], maxCharBudget = 4000) {
|
|
|
90
64
|
text = msg.content
|
|
91
65
|
} else if (Array.isArray(msg.content)) {
|
|
92
66
|
text = msg.content
|
|
93
|
-
.filter((part) => part && part.type === 'text' && typeof part.text === 'string')
|
|
67
|
+
.filter((part) => part && typeof part === 'object' && part.type === 'text' && typeof part.text === 'string')
|
|
94
68
|
.map((part) => part.text)
|
|
95
69
|
.join('\n')
|
|
70
|
+
} else if (msg.content && typeof msg.content === 'object') {
|
|
71
|
+
if (typeof msg.content.text === 'string') {
|
|
72
|
+
text = msg.content.text
|
|
73
|
+
} else if (typeof msg.content.content === 'string') {
|
|
74
|
+
text = msg.content.content
|
|
75
|
+
}
|
|
76
|
+
} else if (typeof msg.text === 'string') {
|
|
77
|
+
text = msg.text
|
|
96
78
|
}
|
|
97
79
|
|
|
98
|
-
|
|
80
|
+
text = text.trim()
|
|
81
|
+
if (!text) continue
|
|
99
82
|
|
|
100
83
|
if (text.length > maxCharBudget) {
|
|
101
84
|
const head = text.slice(0, Math.floor(maxCharBudget * 0.7))
|
|
@@ -118,7 +101,7 @@ export function isBroadPromptRequiringQuestions(userPrompt = '', messages = [])
|
|
|
118
101
|
}
|
|
119
102
|
|
|
120
103
|
const hasRecentQuestion = messages.some((m) => {
|
|
121
|
-
const text = typeof m
|
|
104
|
+
const text = typeof m?.content === 'string' ? m.content : JSON.stringify(m?.content || '')
|
|
122
105
|
return text.includes('Уточнение требований') || text.includes('опросник') || text.includes('Вариант 1')
|
|
123
106
|
})
|
|
124
107
|
if (hasRecentQuestion) {
|
|
@@ -281,56 +264,53 @@ export async function runReferencesParallel(references, messages, options = {},
|
|
|
281
264
|
provider: slot.provider,
|
|
282
265
|
model: slot.model,
|
|
283
266
|
messages: fullMessages,
|
|
284
|
-
temperature: options.
|
|
267
|
+
temperature: options.temperature ?? 0.6,
|
|
285
268
|
maxTokens: options.maxTokens ?? 4096,
|
|
286
269
|
})
|
|
287
270
|
|
|
288
|
-
const timeoutPromise = new Promise((_, reject) =>
|
|
289
|
-
setTimeout(() => reject(new Error(`Timeout after ${
|
|
290
|
-
|
|
271
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
272
|
+
const timer = setTimeout(() => reject(new Error(`Timeout after ${timeoutMs / 1000}s`)), timeoutMs)
|
|
273
|
+
timer.unref?.()
|
|
274
|
+
})
|
|
291
275
|
|
|
292
276
|
const res = await Promise.race([callPromise, timeoutPromise])
|
|
293
|
-
const text =
|
|
294
|
-
const files = extractFileBlocks(text)
|
|
277
|
+
const text = typeof res === 'string' ? res : (res?.content || res?.text || '')
|
|
295
278
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
outputTokens: Math.round(text.length / 4),
|
|
279
|
+
const fallbackUsage = (typeof res === 'object' && res?.usage) ? res.usage : {
|
|
280
|
+
inputTokens: Math.max(1, Math.round(fullMessages.map((m) => m.content).join('').length / 4)),
|
|
281
|
+
outputTokens: Math.max(1, Math.round(text.length / 4)),
|
|
300
282
|
}
|
|
301
|
-
const costInfo = estimateTokenCost(slot,
|
|
283
|
+
const costInfo = estimateTokenCost(slot, fallbackUsage)
|
|
302
284
|
|
|
303
285
|
if (typeof onProgress === 'function') {
|
|
304
|
-
const
|
|
305
|
-
onProgress(`✅ *Кандидат ${i + 1} (${label}) завершил ответ${
|
|
286
|
+
const costStr = costInfo.costUsd > 0 ? ` (~\$${costInfo.costUsd.toFixed(4)})` : ''
|
|
287
|
+
onProgress(`✅ *Кандидат ${i + 1} (${label}) завершил ответ${costStr}*\n`)
|
|
306
288
|
}
|
|
307
289
|
|
|
308
290
|
return {
|
|
309
291
|
index: i + 1,
|
|
292
|
+
slot,
|
|
310
293
|
label,
|
|
311
|
-
|
|
312
|
-
model: slot.model,
|
|
313
|
-
text: text || '(empty response)',
|
|
314
|
-
files,
|
|
294
|
+
text,
|
|
315
295
|
usage: costInfo,
|
|
316
296
|
costUsd: costInfo.costUsd,
|
|
317
297
|
ok: true,
|
|
318
298
|
}
|
|
319
299
|
} catch (err) {
|
|
320
|
-
|
|
300
|
+
const errMsg = err?.message || String(err)
|
|
301
|
+
console.warn(`[dsh-moa] Reference ${label} failed:`, errMsg)
|
|
321
302
|
if (typeof onProgress === 'function') {
|
|
322
|
-
onProgress(`⚠️ *Кандидат ${i + 1} (${label})
|
|
303
|
+
onProgress(`⚠️ *Кандидат ${i + 1} (${label}) ошибка: ${errMsg}*\n`)
|
|
323
304
|
}
|
|
324
305
|
return {
|
|
325
306
|
index: i + 1,
|
|
307
|
+
slot,
|
|
326
308
|
label,
|
|
327
|
-
|
|
328
|
-
model: slot.model,
|
|
329
|
-
text: `[failed: ${err?.message || String(err)}]`,
|
|
330
|
-
files: [],
|
|
309
|
+
text: `[Ошибка модели ${label}: ${errMsg}]`,
|
|
331
310
|
usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 },
|
|
332
311
|
costUsd: 0,
|
|
333
312
|
ok: false,
|
|
313
|
+
error: errMsg,
|
|
334
314
|
}
|
|
335
315
|
}
|
|
336
316
|
})
|
|
@@ -339,217 +319,220 @@ export async function runReferencesParallel(references, messages, options = {},
|
|
|
339
319
|
}
|
|
340
320
|
|
|
341
321
|
/**
|
|
342
|
-
*
|
|
343
|
-
* 1. Checks if questions or refinement needed
|
|
344
|
-
* 2. Parallel candidate proposers write to .moa/candidate-X/
|
|
345
|
-
* 3. Aggregator judges and picks winner (or bypassed in Fast Mode)
|
|
346
|
-
* 4. Promotes winner files, records history and calculates costs
|
|
322
|
+
* Main MoA pipeline runner.
|
|
347
323
|
*/
|
|
348
324
|
export async function runMoAPipeline({
|
|
349
325
|
userPrompt,
|
|
350
326
|
messages = [],
|
|
351
327
|
preset,
|
|
352
328
|
callLlm,
|
|
353
|
-
cwd,
|
|
329
|
+
cwd = process.cwd(),
|
|
354
330
|
onProgress,
|
|
355
|
-
skipQuestions = false,
|
|
356
|
-
prices = {},
|
|
357
331
|
}) {
|
|
358
|
-
|
|
359
|
-
|
|
332
|
+
const startTime = Date.now()
|
|
333
|
+
const referenceModels = preset?.reference_models || [
|
|
334
|
+
{ provider: 'opencode-go', model: 'deepseek-v4-flash' },
|
|
335
|
+
{ provider: 'grok', model: 'grok-build-0.1' },
|
|
336
|
+
]
|
|
337
|
+
const aggregator = preset?.aggregator || { provider: 'codex', model: 'gpt-5.6-sol' }
|
|
338
|
+
const aggLabel = slotLabel(aggregator)
|
|
339
|
+
const refTemp = preset?.reference_temperature ?? 0.6
|
|
340
|
+
const aggTemp = preset?.aggregator_temperature ?? 0.4
|
|
341
|
+
const maxTokens = preset?.max_tokens ?? 4096
|
|
342
|
+
const judgeCriteria = preset?.judge_criteria ?? ''
|
|
343
|
+
const isFastMode = referenceModels.length === 1 && !preset?.force_aggregator
|
|
344
|
+
|
|
345
|
+
// 1. Collect current project workspace context (#6)
|
|
346
|
+
if (typeof onProgress === 'function') {
|
|
347
|
+
onProgress('🔍 *Сканирование контекста проекта...*\n')
|
|
360
348
|
}
|
|
349
|
+
const projectContext = await collectProjectContext(cwd)
|
|
350
|
+
const isRefinement = isRefinementTask(userPrompt, projectContext.files)
|
|
361
351
|
|
|
362
|
-
|
|
363
|
-
const
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
const maxTokens = preset?.max_tokens ?? preset?.maxTokens ?? 4096
|
|
368
|
-
const judgeCriteria = preset?.judge_criteria || preset?.judgeCriteria || ''
|
|
369
|
-
const workDir = cwd || process.cwd()
|
|
370
|
-
|
|
371
|
-
// Collect project context (#6) and check refinement task (#4)
|
|
372
|
-
const projectCtx = await collectProjectContext(workDir, 16000)
|
|
373
|
-
const isRefinement = isRefinementTask(userPrompt, projectCtx.files)
|
|
374
|
-
|
|
375
|
-
// System prompt selection
|
|
352
|
+
// 2. Check if broad prompt requires user questionnaire (#7)
|
|
353
|
+
const askQuestions = preset?.ask_clarifying_questions !== false
|
|
354
|
+
const needsQuestions = !isFastMode && askQuestions && !isRefinement && isBroadPromptRequiringQuestions(userPrompt, messages)
|
|
355
|
+
|
|
356
|
+
// 3. Build enriched prompt for candidates
|
|
376
357
|
let candidateSystemPrompt = REFERENCE_SYSTEM_PROMPT
|
|
377
|
-
if (isRefinement &&
|
|
378
|
-
const fileList =
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
onProgress(`🔄 *[Refinement Mode]: Обнаружен существующий проект (${projectCtx.files.length} файлов). Кандидаты вносят точечные изменения...*\n\n`)
|
|
382
|
-
}
|
|
358
|
+
if (isRefinement && projectContext.files.length > 0) {
|
|
359
|
+
const fileList = projectContext.files.map((f) => `- \`${f.relativePath}\` (${f.content.length} chars)`).join('\n')
|
|
360
|
+
const fileContents = projectContext.files.map((f) => `### File: ${f.relativePath}\n\`\`\`\n${f.content}\n\`\`\``).join('\n\n')
|
|
361
|
+
candidateSystemPrompt += `\n\nExisting project structure:\n${fileList}\n\nProject files:\n${fileContents}\n\nYou are modifying an existing project. Output modified or new files with explicit file paths.`
|
|
383
362
|
}
|
|
384
363
|
|
|
385
|
-
|
|
386
|
-
const needsQuestions = !skipQuestions && !isRefinement && isBroadPromptRequiringQuestions(userPrompt, messages)
|
|
364
|
+
let promptForCandidates = userPrompt
|
|
387
365
|
if (needsQuestions) {
|
|
388
|
-
|
|
389
|
-
|
|
366
|
+
promptForCandidates += '\n(Примечание: задача широкая. Предложи ключевые архитектурные и функциональные развилки для уточнения требований пользователя).'
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const enrichedMessages = [...messages, { role: 'user', content: promptForCandidates }]
|
|
370
|
+
|
|
371
|
+
// 4. Parallel fan-out to candidate models
|
|
372
|
+
if (typeof onProgress === 'function') {
|
|
373
|
+
onProgress(`🚀 *Запуск ${referenceModels.length} моделей-кандидатов параллельно...*\n`)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const referenceOutputs = await runReferencesParallel(
|
|
377
|
+
referenceModels,
|
|
378
|
+
enrichedMessages,
|
|
379
|
+
{ systemPrompt: candidateSystemPrompt, temperature: refTemp, maxTokens },
|
|
380
|
+
callLlm,
|
|
381
|
+
onProgress
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
// Fail fast if all candidates failed (#12)
|
|
385
|
+
const successfulRefs = referenceOutputs.filter((r) => r.ok)
|
|
386
|
+
if (successfulRefs.length === 0) {
|
|
387
|
+
const reasons = referenceOutputs.map((r) => `${r.label}: ${r.error || 'unknown error'}`).join('; ')
|
|
388
|
+
return {
|
|
389
|
+
kind: 'failure',
|
|
390
|
+
content: `⚠️ Все модели-советники (${referenceOutputs.length}) завершились с ошибкой: ${reasons}`,
|
|
391
|
+
aggregator: aggLabel,
|
|
392
|
+
references: referenceOutputs,
|
|
393
|
+
presetName: preset?.name || 'default',
|
|
394
|
+
isRefinement,
|
|
395
|
+
isFastMode,
|
|
396
|
+
winningIndex: 0,
|
|
397
|
+
winnerModel: 'none',
|
|
398
|
+
promotedFiles: [],
|
|
399
|
+
usage: {
|
|
400
|
+
totalTokens: 0,
|
|
401
|
+
totalCostUsd: 0,
|
|
402
|
+
candidates: referenceOutputs.map((r) => ({ label: r.label, usage: r.usage, costUsd: r.costUsd })),
|
|
403
|
+
aggregator: { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 },
|
|
404
|
+
},
|
|
405
|
+
durationMs: Date.now() - startTime,
|
|
390
406
|
}
|
|
407
|
+
}
|
|
391
408
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
)
|
|
409
|
+
// 5. Extract file blocks & write candidate workspaces (#1, #2)
|
|
410
|
+
for (let i = 0; i < referenceOutputs.length; i++) {
|
|
411
|
+
const ref = referenceOutputs[i]
|
|
412
|
+
if (!ref.ok) continue
|
|
413
|
+
const files = extractFileBlocks(ref.text)
|
|
414
|
+
ref.files = files
|
|
415
|
+
if (files.length > 0) {
|
|
416
|
+
await writeCandidateWorkspace(cwd, i + 1, files)
|
|
417
|
+
}
|
|
418
|
+
}
|
|
399
419
|
|
|
420
|
+
// 6. Questionnaire synthesis branch (#7)
|
|
421
|
+
if (needsQuestions) {
|
|
400
422
|
if (typeof onProgress === 'function') {
|
|
401
|
-
onProgress(
|
|
423
|
+
onProgress('📋 *Синтез опросника судьей...*\n')
|
|
402
424
|
}
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
let questionsText = ''
|
|
425
|
+
const questionPrompt = buildQuestionSynthesisPrompt(userPrompt, referenceOutputs)
|
|
426
|
+
let questionsContent = ''
|
|
406
427
|
try {
|
|
407
|
-
const
|
|
428
|
+
const qRes = await callLlm({
|
|
408
429
|
provider: aggregator.provider,
|
|
409
430
|
model: aggregator.model,
|
|
410
|
-
messages: [{ role: 'user', content:
|
|
431
|
+
messages: [{ role: 'user', content: questionPrompt }],
|
|
411
432
|
temperature: 0.3,
|
|
412
|
-
maxTokens:
|
|
433
|
+
maxTokens: 2048,
|
|
413
434
|
})
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
const res = await Promise.race([qPromise, qTimeout])
|
|
418
|
-
questionsText = (typeof res === 'string' ? res : (res?.content || res?.text || '')).trim()
|
|
419
|
-
} catch (err) {
|
|
420
|
-
questionsText = `Не удалось сформировать вопросы судьи: ${err?.message || String(err)}`
|
|
435
|
+
questionsContent = typeof qRes === 'string' ? qRes : (qRes?.content || qRes?.text || '')
|
|
436
|
+
} catch {
|
|
437
|
+
questionsContent = '### Уточнение требований к проекту\nПожалуйста, уточните детали реализации и желаемый стек.'
|
|
421
438
|
}
|
|
422
439
|
|
|
423
440
|
return {
|
|
424
441
|
kind: 'questions',
|
|
425
|
-
content:
|
|
426
|
-
|
|
427
|
-
references: questionOutputs,
|
|
442
|
+
content: questionsContent,
|
|
443
|
+
references: referenceOutputs,
|
|
428
444
|
presetName: preset?.name || 'default',
|
|
445
|
+
isRefinement: false,
|
|
429
446
|
}
|
|
430
447
|
}
|
|
431
448
|
|
|
432
|
-
//
|
|
433
|
-
|
|
449
|
+
// 7. Fast mode bypass for single candidate
|
|
450
|
+
if (isFastMode) {
|
|
451
|
+
const single = referenceOutputs[0]
|
|
452
|
+
let promotedFiles = []
|
|
453
|
+
if (single.files && single.files.length > 0) {
|
|
454
|
+
promotedFiles = await promoteCandidateWorkspace(cwd, 1)
|
|
455
|
+
} else {
|
|
456
|
+
await cleanMoaWorkspaces(cwd)
|
|
457
|
+
}
|
|
434
458
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
const
|
|
438
|
-
onProgress(`🚀 *Запускаю реализацию ${modeLabel}...*\n\n`)
|
|
439
|
-
}
|
|
459
|
+
const totalTokens = single.usage?.totalTokens || 0
|
|
460
|
+
const totalCostUsd = single.costUsd || 0
|
|
461
|
+
const durationMs = Date.now() - startTime
|
|
440
462
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
console.warn(`[dsh-moa] Failed to write candidate ${i + 1} workspace:`, writeErr)
|
|
460
|
-
}
|
|
463
|
+
return {
|
|
464
|
+
kind: 'synthesis',
|
|
465
|
+
content: single.text,
|
|
466
|
+
aggregator: 'Fast Mode (Direct)',
|
|
467
|
+
references: referenceOutputs,
|
|
468
|
+
presetName: preset?.name || 'default',
|
|
469
|
+
isRefinement,
|
|
470
|
+
isFastMode: true,
|
|
471
|
+
winningIndex: 1,
|
|
472
|
+
winnerModel: single.label,
|
|
473
|
+
promotedFiles,
|
|
474
|
+
usage: {
|
|
475
|
+
totalTokens,
|
|
476
|
+
totalCostUsd,
|
|
477
|
+
candidates: [{ label: single.label, usage: single.usage, costUsd: single.costUsd }],
|
|
478
|
+
aggregator: { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 },
|
|
479
|
+
},
|
|
480
|
+
durationMs,
|
|
461
481
|
}
|
|
462
482
|
}
|
|
463
483
|
|
|
484
|
+
// 8. Aggregator / Judge synthesis (#5, #8)
|
|
485
|
+
if (typeof onProgress === 'function') {
|
|
486
|
+
onProgress(`⚖️ *Ведущая модель (${aggLabel}) оценивает варианты и синтезирует решение...*\n`)
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const synthesisPrompt = buildSynthesisPrompt(userPrompt, referenceOutputs, judgeCriteria)
|
|
464
490
|
let synthesizedText = ''
|
|
465
|
-
let winningIndex = 1
|
|
466
491
|
let aggUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 }
|
|
467
|
-
const aggLabel = slotLabel(aggregator)
|
|
468
|
-
|
|
469
|
-
if (isFastMode) {
|
|
470
|
-
// Fast mode: promote candidate 1 directly without judge
|
|
471
|
-
synthesizedText = referenceOutputs[0]?.text || '(empty fast response)'
|
|
472
|
-
winningIndex = 1
|
|
473
|
-
} else {
|
|
474
|
-
// Phase 3: Aggregator evaluation
|
|
475
|
-
if (typeof onProgress === 'function') {
|
|
476
|
-
onProgress(`\n⚖️ *Все кандидаты завершили генерацию. Судья (${aggLabel}) оценивает код и файлы...*\n\n`)
|
|
477
|
-
}
|
|
478
492
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
)
|
|
492
|
-
const res = await Promise.race([aggPromise, aggTimeout])
|
|
493
|
-
synthesizedText = (typeof res === 'string' ? res : (res?.content || res?.text || '')).trim()
|
|
494
|
-
|
|
495
|
-
const rawAggUsage = res?.usage || {
|
|
496
|
-
inputTokens: Math.round(synthPrompt.length / 4),
|
|
497
|
-
outputTokens: Math.round(synthesizedText.length / 4),
|
|
498
|
-
}
|
|
499
|
-
aggUsage = estimateTokenCost(aggregator, rawAggUsage, prices)
|
|
500
|
-
} catch (err) {
|
|
501
|
-
synthesizedText = `[Aggregator error: ${err?.message || String(err)}]\n\nFallback candidate outputs:\n\n` +
|
|
502
|
-
referenceOutputs.map((r, i) => `### ${r.label}\n${r.text}`).join('\n\n')
|
|
493
|
+
try {
|
|
494
|
+
const aggRes = await callLlm({
|
|
495
|
+
provider: aggregator.provider,
|
|
496
|
+
model: aggregator.model,
|
|
497
|
+
messages: [{ role: 'user', content: synthesisPrompt }],
|
|
498
|
+
temperature: aggTemp,
|
|
499
|
+
maxTokens,
|
|
500
|
+
})
|
|
501
|
+
synthesizedText = typeof aggRes === 'string' ? aggRes : (aggRes?.content || aggRes?.text || '')
|
|
502
|
+
const aggFallbackUsage = (typeof aggRes === 'object' && aggRes?.usage) ? aggRes.usage : {
|
|
503
|
+
inputTokens: Math.max(1, Math.round(synthesisPrompt.length / 4)),
|
|
504
|
+
outputTokens: Math.max(1, Math.round(synthesizedText.length / 4)),
|
|
503
505
|
}
|
|
504
|
-
|
|
505
|
-
|
|
506
|
+
aggUsage = estimateTokenCost(aggregator, aggFallbackUsage)
|
|
507
|
+
} catch (err) {
|
|
508
|
+
console.warn(`[dsh-moa] Aggregator ${aggLabel} failed:`, err)
|
|
509
|
+
synthesizedText = `⚠️ *[Aggregator error: ${err?.message || err}. Fallback candidate outputs:]*\n\n` +
|
|
510
|
+
successfulRefs.map((r, i) => `### Кандидат ${i + 1} (${r.label})\n${r.text}`).join('\n\n')
|
|
506
511
|
}
|
|
507
512
|
|
|
508
|
-
//
|
|
513
|
+
// 9. Evaluate winner & promote files (#1, #2)
|
|
514
|
+
const winningIndex = parseWinnerIndex(synthesizedText, 1)
|
|
509
515
|
let promotedFiles = []
|
|
510
|
-
|
|
511
|
-
promotedFiles = await promoteCandidateWorkspace(
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
}
|
|
516
|
-
|
|
516
|
+
if (referenceOutputs[winningIndex - 1]?.files?.length > 0) {
|
|
517
|
+
promotedFiles = await promoteCandidateWorkspace(cwd, winningIndex)
|
|
518
|
+
} else if (successfulRefs[0]?.files?.length > 0) {
|
|
519
|
+
const fallbackIdx = successfulRefs[0].index
|
|
520
|
+
promotedFiles = await promoteCandidateWorkspace(cwd, fallbackIdx)
|
|
521
|
+
} else {
|
|
522
|
+
await cleanMoaWorkspaces(cwd)
|
|
517
523
|
}
|
|
518
524
|
|
|
519
|
-
//
|
|
525
|
+
// 10. Live Canvas preview (#16)
|
|
520
526
|
let liveCanvas = null
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
method: 'POST',
|
|
528
|
-
headers: { 'Content-Type': 'application/json' },
|
|
529
|
-
body: JSON.stringify({ filePath: previewCandidate }),
|
|
530
|
-
signal: AbortSignal.timeout(500),
|
|
531
|
-
})
|
|
532
|
-
if (lcRes.ok) {
|
|
533
|
-
const lcData = await lcRes.json()
|
|
534
|
-
if (lcData?.canvasId) {
|
|
535
|
-
liveCanvas = {
|
|
536
|
-
canvasId: lcData.canvasId,
|
|
537
|
-
title: lcData.title || previewCandidate,
|
|
538
|
-
filePath: previewCandidate,
|
|
539
|
-
previewUrl: lcData.previewUrl || `/dsh-live-canvas/sandbox/${lcData.canvasId}`
|
|
540
|
-
}
|
|
541
|
-
if (typeof onProgress === 'function') {
|
|
542
|
-
onProgress(`\n🎨 *[Live Canvas]: Файл ${previewCandidate} открыт для предпросмотра!*\n\n`)
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
} catch (err) {
|
|
547
|
-
// live-canvas plugin might not be installed or reachable, non-fatal
|
|
548
|
-
}
|
|
527
|
+
const htmlFile = promotedFiles.find((f) => f.endsWith('.html') || f.endsWith('index.html'))
|
|
528
|
+
if (htmlFile) {
|
|
529
|
+
liveCanvas = {
|
|
530
|
+
previewUrl: `http://localhost:3000/preview/${encodeURIComponent(htmlFile)}`,
|
|
531
|
+
file: htmlFile,
|
|
532
|
+
title: 'Interactive Web Preview',
|
|
549
533
|
}
|
|
550
534
|
}
|
|
551
535
|
|
|
552
|
-
// Calculate totals
|
|
553
536
|
const totalTokens = referenceOutputs.reduce((acc, r) => acc + (r.usage?.totalTokens || 0), 0) + aggUsage.totalTokens
|
|
554
537
|
const totalCostUsd = Number((referenceOutputs.reduce((acc, r) => acc + (r.costUsd || 0), 0) + aggUsage.costUsd).toFixed(5))
|
|
555
538
|
const durationMs = Date.now() - startTime
|
|
@@ -627,6 +610,12 @@ export function formatMoAResponse({ moaResult, presetName }) {
|
|
|
627
610
|
return parts.join('\n')
|
|
628
611
|
}
|
|
629
612
|
|
|
613
|
+
if (moaResult?.kind === 'failure') {
|
|
614
|
+
parts.push('## ⚠️ Mixture of Agents — Сбой выполнения')
|
|
615
|
+
parts.push(moaResult.content)
|
|
616
|
+
return parts.join('\n')
|
|
617
|
+
}
|
|
618
|
+
|
|
630
619
|
const modeBadge = moaResult?.isFastMode ? '⚡ Fast Mode' : `Судья: ${judge}`
|
|
631
620
|
parts.push(`## 🧠 Mixture of Agents (Пресет: ${pName} | ${modeBadge})`)
|
|
632
621
|
parts.push('')
|
|
@@ -691,73 +680,94 @@ export async function* streamMoATurn({ targetPreset, userPrompt, messages, callL
|
|
|
691
680
|
yield { type: 'block-start', index: 0, blockType: 'text' }
|
|
692
681
|
yield { type: 'text-delta', index: 0, text: '🧠 *Mixture of Agents запущен...*\n\n' }
|
|
693
682
|
|
|
694
|
-
//
|
|
695
|
-
const
|
|
696
|
-
|
|
697
|
-
|
|
683
|
+
// Async push-queue for zero-latency live delta streaming
|
|
684
|
+
const queue = []
|
|
685
|
+
let notify = null
|
|
686
|
+
|
|
687
|
+
const pushUpdate = (text) => {
|
|
688
|
+
queue.push({ type: 'text-delta', index: 0, text })
|
|
689
|
+
if (notify) {
|
|
690
|
+
notify()
|
|
691
|
+
notify = null
|
|
692
|
+
}
|
|
698
693
|
}
|
|
699
694
|
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
695
|
+
let done = false
|
|
696
|
+
const pipelinePromise = runMoAPipeline({
|
|
697
|
+
userPrompt,
|
|
698
|
+
messages,
|
|
699
|
+
preset: targetPreset,
|
|
700
|
+
callLlm,
|
|
701
|
+
cwd,
|
|
702
|
+
onProgress: pushUpdate,
|
|
703
|
+
})
|
|
704
|
+
.catch((err) => ({ error: err }))
|
|
705
|
+
.finally(() => {
|
|
706
|
+
done = true
|
|
707
|
+
if (notify) {
|
|
708
|
+
notify()
|
|
709
|
+
notify = null
|
|
710
|
+
}
|
|
708
711
|
})
|
|
709
712
|
|
|
710
|
-
|
|
711
|
-
|
|
713
|
+
const startTime = Date.now()
|
|
714
|
+
let lastYieldTime = Date.now()
|
|
712
715
|
|
|
713
|
-
|
|
714
|
-
while (
|
|
715
|
-
if (signal?.aborted)
|
|
716
|
+
try {
|
|
717
|
+
while (!done || queue.length > 0) {
|
|
718
|
+
if (signal?.aborted) {
|
|
719
|
+
await cleanMoaWorkspaces(cwd)
|
|
720
|
+
return
|
|
721
|
+
}
|
|
716
722
|
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
yield { type: 'text-delta', index: 0, text: chunk }
|
|
721
|
-
emittedAny = true
|
|
723
|
+
while (queue.length > 0) {
|
|
724
|
+
const item = queue.shift()
|
|
725
|
+
yield item
|
|
722
726
|
lastYieldTime = Date.now()
|
|
723
727
|
}
|
|
724
728
|
|
|
725
|
-
const done = await Promise.race([
|
|
726
|
-
pipelinePromise.then(() => true),
|
|
727
|
-
new Promise((r) => { const t = setTimeout(() => r(false), 500); t.unref?.(); }),
|
|
728
|
-
])
|
|
729
729
|
if (done) break
|
|
730
730
|
|
|
731
|
+
// Wait for next push item or max 2.5s heartbeat
|
|
732
|
+
await Promise.race([
|
|
733
|
+
new Promise((resolve) => { notify = resolve }),
|
|
734
|
+
new Promise((resolve) => { const t = setTimeout(resolve, 2500); t.unref?.(); }),
|
|
735
|
+
])
|
|
736
|
+
|
|
731
737
|
const elapsedSec = Math.floor((Date.now() - startTime) / 1000)
|
|
732
|
-
|
|
733
|
-
if (!emittedAny && Date.now() - lastYieldTime >= 3000) {
|
|
738
|
+
if (!done && Date.now() - lastYieldTime >= 3000) {
|
|
734
739
|
yield { type: 'text-delta', index: 0, text: `⏳ *[${elapsedSec}с] Идёт обработка...*\n` }
|
|
735
740
|
lastYieldTime = Date.now()
|
|
736
741
|
}
|
|
737
742
|
}
|
|
738
743
|
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
744
|
+
const result = await pipelinePromise
|
|
745
|
+
if (signal?.aborted) {
|
|
746
|
+
await cleanMoaWorkspaces(cwd)
|
|
747
|
+
return
|
|
742
748
|
}
|
|
743
749
|
|
|
744
|
-
|
|
745
|
-
|
|
750
|
+
if (result?.error) {
|
|
751
|
+
const errText = '\n\n⚠️ **Ошибка Mixture of Agents**: ' + (result.error?.message || String(result.error))
|
|
752
|
+
yield { type: 'text-delta', index: 0, text: errText }
|
|
753
|
+
yield { type: 'block-end', index: 0, block: { type: 'text', text: errText } }
|
|
754
|
+
yield { type: 'finish', reason: { kind: 'stop' } }
|
|
755
|
+
return
|
|
756
|
+
}
|
|
746
757
|
|
|
747
758
|
const formatted = formatMoAResponse({
|
|
748
|
-
moaResult,
|
|
759
|
+
moaResult: result,
|
|
749
760
|
presetName: targetPreset?.name || 'default',
|
|
750
761
|
})
|
|
751
762
|
|
|
752
763
|
yield { type: 'text-delta', index: 0, text: '\n---\n\n' + formatted }
|
|
753
764
|
yield { type: 'block-end', index: 0, block: { type: 'text', text: formatted } }
|
|
754
|
-
yield { type: 'usage', usage: { inputTokens:
|
|
755
|
-
yield { type: 'finish', reason: { kind: 'stop' } }
|
|
756
|
-
} catch (err) {
|
|
757
|
-
if (signal?.aborted) return
|
|
758
|
-
const errText = '\n\n⚠️ **Ошибка Mixture of Agents**: ' + (err?.message || String(err))
|
|
759
|
-
yield { type: 'text-delta', index: 0, text: errText }
|
|
760
|
-
yield { type: 'block-end', index: 0, block: { type: 'text', text: errText } }
|
|
765
|
+
yield { type: 'usage', usage: { inputTokens: result?.usage?.totalTokens || 100, outputTokens: formatted.length } }
|
|
761
766
|
yield { type: 'finish', reason: { kind: 'stop' } }
|
|
767
|
+
} finally {
|
|
768
|
+
if (signal?.aborted) {
|
|
769
|
+
await cleanMoaWorkspaces(cwd)
|
|
770
|
+
}
|
|
762
771
|
}
|
|
763
772
|
}
|
|
773
|
+
|