@goodandready/dsh-moa 0.2.8 → 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 +489 -463
- package/lib/moa-runner.js +228 -265
- 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,244 +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 allowQuestions = preset?.ask_clarifying_questions !== false && preset?.askClarifyingQuestions !== false
|
|
387
|
-
const needsQuestions = allowQuestions && !skipQuestions && !isRefinement && isBroadPromptRequiringQuestions(userPrompt, messages)
|
|
364
|
+
let promptForCandidates = userPrompt
|
|
388
365
|
if (needsQuestions) {
|
|
389
|
-
|
|
390
|
-
onProgress('🔍 *Задача общего характера. Советники формируют ключевые развилки...*\n\n')
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
const questionOutputs = await runReferencesParallel(
|
|
394
|
-
referenceModels,
|
|
395
|
-
[...messages, { role: 'user', content: userPrompt }],
|
|
396
|
-
{ systemPrompt: ADVISOR_QUESTION_PROMPT, referenceTemperature: 0.5, maxTokens: 1024 },
|
|
397
|
-
callLlm,
|
|
398
|
-
onProgress,
|
|
399
|
-
)
|
|
400
|
-
|
|
401
|
-
if (typeof onProgress === 'function') {
|
|
402
|
-
onProgress(`\n⚖️ *Судья (${slotLabel(aggregator)}) синтезирует единый опросник для вас...*\n\n`)
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
const qSynthPrompt = buildQuestionSynthesisPrompt(userPrompt, questionOutputs)
|
|
406
|
-
let questionsText = ''
|
|
407
|
-
try {
|
|
408
|
-
const qPromise = callLlm({
|
|
409
|
-
provider: aggregator.provider,
|
|
410
|
-
model: aggregator.model,
|
|
411
|
-
messages: [{ role: 'user', content: qSynthPrompt }],
|
|
412
|
-
temperature: 0.3,
|
|
413
|
-
maxTokens: 1500,
|
|
414
|
-
})
|
|
415
|
-
const qTimeout = new Promise((_, reject) =>
|
|
416
|
-
setTimeout(() => reject(new Error('Timeout waiting for judge questions synthesis')), 60000).unref()
|
|
417
|
-
)
|
|
418
|
-
const res = await Promise.race([qPromise, qTimeout])
|
|
419
|
-
questionsText = (typeof res === 'string' ? res : (res?.content || res?.text || '')).trim()
|
|
420
|
-
} catch (err) {
|
|
421
|
-
questionsText = `Не удалось сформировать вопросы судьи: ${err?.message || String(err)}`
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
return {
|
|
425
|
-
kind: 'questions',
|
|
426
|
-
content: questionsText,
|
|
427
|
-
aggregator: slotLabel(aggregator),
|
|
428
|
-
references: questionOutputs,
|
|
429
|
-
presetName: preset?.name || 'default',
|
|
430
|
-
}
|
|
366
|
+
promptForCandidates += '\n(Примечание: задача широкая. Предложи ключевые архитектурные и функциональные развилки для уточнения требований пользователя).'
|
|
431
367
|
}
|
|
432
368
|
|
|
433
|
-
|
|
434
|
-
const isFastMode = referenceModels.length === 1
|
|
369
|
+
const enrichedMessages = [...messages, { role: 'user', content: promptForCandidates }]
|
|
435
370
|
|
|
436
|
-
//
|
|
371
|
+
// 4. Parallel fan-out to candidate models
|
|
437
372
|
if (typeof onProgress === 'function') {
|
|
438
|
-
|
|
439
|
-
onProgress(`🚀 *Запускаю реализацию ${modeLabel}...*\n\n`)
|
|
373
|
+
onProgress(`🚀 *Запуск ${referenceModels.length} моделей-кандидатов параллельно...*\n`)
|
|
440
374
|
}
|
|
441
375
|
|
|
442
376
|
const referenceOutputs = await runReferencesParallel(
|
|
443
377
|
referenceModels,
|
|
444
|
-
|
|
445
|
-
{ systemPrompt: candidateSystemPrompt,
|
|
378
|
+
enrichedMessages,
|
|
379
|
+
{ systemPrompt: candidateSystemPrompt, temperature: refTemp, maxTokens },
|
|
446
380
|
callLlm,
|
|
447
|
-
onProgress
|
|
381
|
+
onProgress
|
|
448
382
|
)
|
|
449
383
|
|
|
450
|
-
//
|
|
451
|
-
const
|
|
452
|
-
if (
|
|
453
|
-
const
|
|
454
|
-
.map((r, i) => `• Кандидат ${i + 1} (${r.label}): ${r.text}`)
|
|
455
|
-
.join('\n')
|
|
456
|
-
const failMessage = `⚠️ **Все модели-советники (${referenceOutputs.length}) завершились с ошибкой**.\n\nСудья не вызывался для предотвращения бессмысленного расхода токенов.\n\n### Детали ошибок:\n${errorDetails}`
|
|
457
|
-
|
|
458
|
-
await cleanMoaWorkspaces(workDir)
|
|
459
|
-
|
|
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('; ')
|
|
460
388
|
return {
|
|
461
389
|
kind: 'failure',
|
|
462
|
-
content:
|
|
463
|
-
aggregator:
|
|
390
|
+
content: `⚠️ Все модели-советники (${referenceOutputs.length}) завершились с ошибкой: ${reasons}`,
|
|
391
|
+
aggregator: aggLabel,
|
|
464
392
|
references: referenceOutputs,
|
|
465
393
|
presetName: preset?.name || 'default',
|
|
394
|
+
isRefinement,
|
|
395
|
+
isFastMode,
|
|
396
|
+
winningIndex: 0,
|
|
397
|
+
winnerModel: 'none',
|
|
466
398
|
promotedFiles: [],
|
|
467
399
|
usage: {
|
|
468
400
|
totalTokens: 0,
|
|
469
401
|
totalCostUsd: 0,
|
|
470
|
-
candidates: referenceOutputs.map((r) => ({ label: r.label, usage: r.usage, costUsd:
|
|
402
|
+
candidates: referenceOutputs.map((r) => ({ label: r.label, usage: r.usage, costUsd: r.costUsd })),
|
|
471
403
|
aggregator: { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 },
|
|
472
404
|
},
|
|
473
405
|
durationMs: Date.now() - startTime,
|
|
474
406
|
}
|
|
475
407
|
}
|
|
476
|
-
|
|
408
|
+
|
|
409
|
+
// 5. Extract file blocks & write candidate workspaces (#1, #2)
|
|
477
410
|
for (let i = 0; i < referenceOutputs.length; i++) {
|
|
478
411
|
const ref = referenceOutputs[i]
|
|
479
|
-
if (ref.ok
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
}
|
|
485
|
-
} catch (writeErr) {
|
|
486
|
-
console.warn(`[dsh-moa] Failed to write candidate ${i + 1} workspace:`, writeErr)
|
|
487
|
-
}
|
|
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)
|
|
488
417
|
}
|
|
489
418
|
}
|
|
490
419
|
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
let aggUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 }
|
|
494
|
-
const aggLabel = slotLabel(aggregator)
|
|
495
|
-
|
|
496
|
-
if (isFastMode) {
|
|
497
|
-
// Fast mode: promote candidate 1 directly without judge
|
|
498
|
-
synthesizedText = referenceOutputs[0]?.text || '(empty fast response)'
|
|
499
|
-
winningIndex = 1
|
|
500
|
-
} else {
|
|
501
|
-
// Phase 3: Aggregator evaluation
|
|
420
|
+
// 6. Questionnaire synthesis branch (#7)
|
|
421
|
+
if (needsQuestions) {
|
|
502
422
|
if (typeof onProgress === 'function') {
|
|
503
|
-
onProgress(
|
|
423
|
+
onProgress('📋 *Синтез опросника судьей...*\n')
|
|
504
424
|
}
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
425
|
+
const questionPrompt = buildQuestionSynthesisPrompt(userPrompt, referenceOutputs)
|
|
426
|
+
let questionsContent = ''
|
|
508
427
|
try {
|
|
509
|
-
const
|
|
428
|
+
const qRes = await callLlm({
|
|
510
429
|
provider: aggregator.provider,
|
|
511
430
|
model: aggregator.model,
|
|
512
|
-
messages: [{ role: 'user', content:
|
|
513
|
-
temperature:
|
|
514
|
-
maxTokens,
|
|
431
|
+
messages: [{ role: 'user', content: questionPrompt }],
|
|
432
|
+
temperature: 0.3,
|
|
433
|
+
maxTokens: 2048,
|
|
515
434
|
})
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
const res = await Promise.race([aggPromise, aggTimeout])
|
|
520
|
-
synthesizedText = (typeof res === 'string' ? res : (res?.content || res?.text || '')).trim()
|
|
521
|
-
|
|
522
|
-
const rawAggUsage = res?.usage || {
|
|
523
|
-
inputTokens: Math.round(synthPrompt.length / 4),
|
|
524
|
-
outputTokens: Math.round(synthesizedText.length / 4),
|
|
525
|
-
}
|
|
526
|
-
aggUsage = estimateTokenCost(aggregator, rawAggUsage, prices)
|
|
527
|
-
} catch (err) {
|
|
528
|
-
synthesizedText = `[Aggregator error: ${err?.message || String(err)}]\n\nFallback candidate outputs:\n\n` +
|
|
529
|
-
referenceOutputs.map((r, i) => `### ${r.label}\n${r.text}`).join('\n\n')
|
|
435
|
+
questionsContent = typeof qRes === 'string' ? qRes : (qRes?.content || qRes?.text || '')
|
|
436
|
+
} catch {
|
|
437
|
+
questionsContent = '### Уточнение требований к проекту\nПожалуйста, уточните детали реализации и желаемый стек.'
|
|
530
438
|
}
|
|
531
439
|
|
|
532
|
-
|
|
440
|
+
return {
|
|
441
|
+
kind: 'questions',
|
|
442
|
+
content: questionsContent,
|
|
443
|
+
references: referenceOutputs,
|
|
444
|
+
presetName: preset?.name || 'default',
|
|
445
|
+
isRefinement: false,
|
|
446
|
+
}
|
|
533
447
|
}
|
|
534
448
|
|
|
535
|
-
//
|
|
536
|
-
|
|
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
|
+
}
|
|
458
|
+
|
|
459
|
+
const totalTokens = single.usage?.totalTokens || 0
|
|
460
|
+
const totalCostUsd = single.costUsd || 0
|
|
461
|
+
const durationMs = Date.now() - startTime
|
|
462
|
+
|
|
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,
|
|
481
|
+
}
|
|
482
|
+
}
|
|
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)
|
|
490
|
+
let synthesizedText = ''
|
|
491
|
+
let aggUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 }
|
|
492
|
+
|
|
537
493
|
try {
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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)),
|
|
541
505
|
}
|
|
542
|
-
|
|
543
|
-
|
|
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')
|
|
544
511
|
}
|
|
545
512
|
|
|
546
|
-
//
|
|
513
|
+
// 9. Evaluate winner & promote files (#1, #2)
|
|
514
|
+
const winningIndex = parseWinnerIndex(synthesizedText, 1)
|
|
515
|
+
let promotedFiles = []
|
|
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)
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// 10. Live Canvas preview (#16)
|
|
547
526
|
let liveCanvas = null
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
method: 'POST',
|
|
555
|
-
headers: { 'Content-Type': 'application/json' },
|
|
556
|
-
body: JSON.stringify({ filePath: previewCandidate }),
|
|
557
|
-
signal: AbortSignal.timeout(500),
|
|
558
|
-
})
|
|
559
|
-
if (lcRes.ok) {
|
|
560
|
-
const lcData = await lcRes.json()
|
|
561
|
-
if (lcData?.canvasId) {
|
|
562
|
-
liveCanvas = {
|
|
563
|
-
canvasId: lcData.canvasId,
|
|
564
|
-
title: lcData.title || previewCandidate,
|
|
565
|
-
filePath: previewCandidate,
|
|
566
|
-
previewUrl: lcData.previewUrl || `/dsh-live-canvas/sandbox/${lcData.canvasId}`
|
|
567
|
-
}
|
|
568
|
-
if (typeof onProgress === 'function') {
|
|
569
|
-
onProgress(`\n🎨 *[Live Canvas]: Файл ${previewCandidate} открыт для предпросмотра!*\n\n`)
|
|
570
|
-
}
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
} catch (err) {
|
|
574
|
-
// live-canvas plugin might not be installed or reachable, non-fatal
|
|
575
|
-
}
|
|
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',
|
|
576
533
|
}
|
|
577
534
|
}
|
|
578
535
|
|
|
579
|
-
// Calculate totals
|
|
580
536
|
const totalTokens = referenceOutputs.reduce((acc, r) => acc + (r.usage?.totalTokens || 0), 0) + aggUsage.totalTokens
|
|
581
537
|
const totalCostUsd = Number((referenceOutputs.reduce((acc, r) => acc + (r.costUsd || 0), 0) + aggUsage.costUsd).toFixed(5))
|
|
582
538
|
const durationMs = Date.now() - startTime
|
|
@@ -654,6 +610,12 @@ export function formatMoAResponse({ moaResult, presetName }) {
|
|
|
654
610
|
return parts.join('\n')
|
|
655
611
|
}
|
|
656
612
|
|
|
613
|
+
if (moaResult?.kind === 'failure') {
|
|
614
|
+
parts.push('## ⚠️ Mixture of Agents — Сбой выполнения')
|
|
615
|
+
parts.push(moaResult.content)
|
|
616
|
+
return parts.join('\n')
|
|
617
|
+
}
|
|
618
|
+
|
|
657
619
|
const modeBadge = moaResult?.isFastMode ? '⚡ Fast Mode' : `Судья: ${judge}`
|
|
658
620
|
parts.push(`## 🧠 Mixture of Agents (Пресет: ${pName} | ${modeBadge})`)
|
|
659
621
|
parts.push('')
|
|
@@ -808,3 +770,4 @@ export async function* streamMoATurn({ targetPreset, userPrompt, messages, callL
|
|
|
808
770
|
}
|
|
809
771
|
}
|
|
810
772
|
}
|
|
773
|
+
|