@goodandready/dsh-moa 0.2.9 → 0.2.11
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/README.md +66 -37
- package/docs/README.ru.md +66 -37
- package/docs/README.zh.md +70 -27
- package/docs/design/DESIGN.md +16 -6
- package/docs/plans/47-client-js-split-plan.md +57 -0
- package/docs/plans/55-resilience-plan.md +33 -0
- package/lib/client.js +260 -85
- package/lib/file-workspace.js +3 -1
- package/lib/history.js +88 -33
- package/lib/index.js +70 -13
- package/lib/live-canvas.js +34 -0
- package/lib/moa-parser.js +181 -0
- package/lib/moa-prompts.js +251 -0
- package/lib/moa-runner.js +477 -473
- package/package.json +1 -1
package/lib/moa-runner.js
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Mixture of Agents (MoA)
|
|
2
|
+
* DeepSeek Harness Mixture of Agents (MoA) — Runner Engine
|
|
3
3
|
*
|
|
4
|
-
* Implements:
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* 7. Real-time cost tracking, token metrics and history logging
|
|
12
|
-
* 8. Zero-latency async push-queue streaming for llm/stream turns
|
|
4
|
+
* Implements the full ensemble pipeline:
|
|
5
|
+
* - Parallel fan-out to reference models with transient retry & quorum straggler mitigation
|
|
6
|
+
* - Prompt caching aligned message structures
|
|
7
|
+
* - Curator synthesis & antipatterns evaluation
|
|
8
|
+
* - Aggregator fallback chain for resilience & malformed output recovery
|
|
9
|
+
* - Live token streaming for aggregator
|
|
10
|
+
* - File promotion & Live Canvas sandbox preview
|
|
13
11
|
*/
|
|
14
12
|
|
|
13
|
+
import path from 'node:path'
|
|
15
14
|
import {
|
|
16
15
|
extractFileBlocks,
|
|
17
16
|
collectProjectContext,
|
|
@@ -20,306 +19,275 @@ import {
|
|
|
20
19
|
promoteCandidateWorkspace,
|
|
21
20
|
cleanMoaWorkspaces,
|
|
22
21
|
} from './file-workspace.js'
|
|
23
|
-
|
|
24
22
|
import { estimateTokenCost } from './pricing.js'
|
|
25
|
-
import { recordMoaRun } from './history.js'
|
|
23
|
+
import { recordMoaRun, recordMoaRunAsync } from './history.js'
|
|
24
|
+
import {
|
|
25
|
+
slotLabel,
|
|
26
|
+
cleanAdvisoryMessages,
|
|
27
|
+
isBroadPromptRequiringQuestions,
|
|
28
|
+
buildQuestionSynthesisPrompt,
|
|
29
|
+
buildCuratorSynthesisPrompt,
|
|
30
|
+
buildSynthesisPrompt,
|
|
31
|
+
SYSTEM_ROLE_PROPOSER,
|
|
32
|
+
ANTIPATTERNS_RUBRIC,
|
|
33
|
+
} from './moa-prompts.js'
|
|
34
|
+
import {
|
|
35
|
+
parseWinnerIndex,
|
|
36
|
+
parseRecommendedAssembler,
|
|
37
|
+
parseMoACommand,
|
|
38
|
+
stripOrSummarizeCode,
|
|
39
|
+
formatMoAResponse,
|
|
40
|
+
} from './moa-parser.js'
|
|
41
|
+
|
|
42
|
+
// Re-export prompt and parser functions for external consumers / backwards compatibility
|
|
43
|
+
export {
|
|
44
|
+
slotLabel,
|
|
45
|
+
cleanAdvisoryMessages,
|
|
46
|
+
isBroadPromptRequiringQuestions,
|
|
47
|
+
buildQuestionSynthesisPrompt,
|
|
48
|
+
buildCuratorSynthesisPrompt,
|
|
49
|
+
buildSynthesisPrompt,
|
|
50
|
+
ANTIPATTERNS_RUBRIC,
|
|
51
|
+
} from './moa-prompts.js'
|
|
52
|
+
|
|
53
|
+
export {
|
|
54
|
+
parseWinnerIndex,
|
|
55
|
+
parseRecommendedAssembler,
|
|
56
|
+
parseMoACommand,
|
|
57
|
+
stripOrSummarizeCode,
|
|
58
|
+
formatMoAResponse,
|
|
59
|
+
} from './moa-parser.js'
|
|
26
60
|
|
|
27
61
|
export { estimateTokenCost } from './pricing.js'
|
|
28
62
|
|
|
29
63
|
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
|
-
\`\`\``
|
|
41
|
-
|
|
42
|
-
export function slotLabel(slot) {
|
|
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'
|
|
47
|
-
}
|
|
64
|
+
export const REFERENCE_SYSTEM_PROMPT = SYSTEM_ROLE_PROPOSER
|
|
48
65
|
|
|
49
66
|
/**
|
|
50
|
-
*
|
|
51
|
-
* Robust against complex DSH message content types (strings, part arrays, objects, tool calls).
|
|
67
|
+
* Invokes LLM call with transient retry for recoverable network/rate-limit errors.
|
|
52
68
|
*/
|
|
53
|
-
export function
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
} else if (Array.isArray(msg.content)) {
|
|
66
|
-
text = msg.content
|
|
67
|
-
.filter((part) => part && typeof part === 'object' && part.type === 'text' && typeof part.text === 'string')
|
|
68
|
-
.map((part) => part.text)
|
|
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
|
|
69
|
+
export async function callWithTransientRetry(callLlmFn, callArgs, maxRetries = 0, retryDelayMs = 1200) {
|
|
70
|
+
let attempt = 0
|
|
71
|
+
while (true) {
|
|
72
|
+
try {
|
|
73
|
+
return await callLlmFn(callArgs)
|
|
74
|
+
} catch (err) {
|
|
75
|
+
attempt++
|
|
76
|
+
const msg = err?.message || String(err)
|
|
77
|
+
const isTransient = /429|rate limit|502|503|504|econnreset|etimedout|socket hang up/i.test(msg)
|
|
78
|
+
if (attempt <= maxRetries && isTransient) {
|
|
79
|
+
await new Promise((r) => setTimeout(r, retryDelayMs))
|
|
80
|
+
continue
|
|
75
81
|
}
|
|
76
|
-
|
|
77
|
-
text = msg.text
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
text = text.trim()
|
|
81
|
-
if (!text) continue
|
|
82
|
-
|
|
83
|
-
if (text.length > maxCharBudget) {
|
|
84
|
-
const head = text.slice(0, Math.floor(maxCharBudget * 0.7))
|
|
85
|
-
const tail = text.slice(-Math.floor(maxCharBudget * 0.3))
|
|
86
|
-
text = `${head}\n... [trimmed ${text.length - maxCharBudget} characters] ...\n${tail}`
|
|
82
|
+
throw err
|
|
87
83
|
}
|
|
88
|
-
|
|
89
|
-
trimmed.push({ role, content: text })
|
|
90
84
|
}
|
|
91
|
-
return trimmed
|
|
92
85
|
}
|
|
93
86
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
return false
|
|
87
|
+
/**
|
|
88
|
+
* Dispatches queries to all reference models in parallel with transient retry and quorum straggler mitigation.
|
|
89
|
+
*/
|
|
90
|
+
export async function runReferencesParallel(references, messages, options = {}, callLlm, onProgress) {
|
|
91
|
+
if (!Array.isArray(references) || references.length === 0) {
|
|
92
|
+
return []
|
|
101
93
|
}
|
|
102
94
|
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
95
|
+
const advisoryMessages = cleanAdvisoryMessages(messages)
|
|
96
|
+
const systemPrompt = options.systemPrompt || REFERENCE_SYSTEM_PROMPT
|
|
97
|
+
// Deterministic prefix for optimal prompt caching hit rate
|
|
98
|
+
const fullMessages = [{ role: 'system', content: systemPrompt }, ...advisoryMessages]
|
|
99
|
+
const timeoutMs = options.timeoutMs ?? ((options.timeoutSec ?? 60) * 1000)
|
|
100
|
+
const maxRetries = options.maxRetries ?? 0
|
|
101
|
+
const quorumEnabled = Boolean(options.quorumEnabled)
|
|
102
|
+
const gracePeriodMs = (options.gracePeriodSec ?? 10) * 1000
|
|
110
103
|
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const startsWithCreation = creationTriggers.some((t) => p.toLowerCase().startsWith(t))
|
|
104
|
+
const total = references.length
|
|
105
|
+
let finishedCount = 0
|
|
106
|
+
const results = new Array(total)
|
|
107
|
+
const abortControllers = references.map(() => new AbortController())
|
|
116
108
|
|
|
117
|
-
|
|
118
|
-
|
|
109
|
+
let onTaskFinished = null
|
|
110
|
+
const notifyFinished = () => {
|
|
111
|
+
if (typeof onTaskFinished === 'function') onTaskFinished()
|
|
119
112
|
}
|
|
120
113
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
if (wordCount <= 12) return true
|
|
114
|
+
if (typeof onProgress === 'function') {
|
|
115
|
+
onProgress(`⚡ *Launching ${total} candidate models in parallel...*\n`)
|
|
124
116
|
}
|
|
125
117
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
export function buildQuestionSynthesisPrompt(userPrompt, referenceOutputs = []) {
|
|
130
|
-
const joined = referenceOutputs
|
|
131
|
-
.map((r, i) => `Советник ${i + 1} (${r.label}):\n${r.text}`)
|
|
132
|
-
.join('\n\n')
|
|
133
|
-
|
|
134
|
-
return `Ты — ведущий архитектор и судья в архитектуре Mixture of Agents (MoA).
|
|
135
|
-
Пользователь дал задачу:
|
|
136
|
-
"${userPrompt}"
|
|
137
|
-
|
|
138
|
-
Советники предложили следующие развилки и уточнения:
|
|
139
|
-
${joined}
|
|
140
|
-
|
|
141
|
-
Твоя задача — синтезировать единый, компактный, дружелюбный и структурированный опросник (2-4 вопроса) для пользователя на русском языке.
|
|
142
|
-
Каждый вопрос должен предлагать 2-3 конкретных рекомендуемых варианта ответа (например: 1. Формат: HTML/JS в одном файле или React? 2. Стиль: Минимализм, iOS или Необрутализм?).
|
|
143
|
-
В конце добавь примечание, что пользователь может ответить кратко (например: "1, 2, темная тема") или довериться выбору по умолчанию.`
|
|
144
|
-
}
|
|
118
|
+
references.forEach((slot, i) => {
|
|
119
|
+
const label = slotLabel(slot)
|
|
145
120
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
121
|
+
const runOne = async () => {
|
|
122
|
+
try {
|
|
123
|
+
const callPromise = callWithTransientRetry(
|
|
124
|
+
callLlm,
|
|
125
|
+
{
|
|
126
|
+
provider: slot.provider,
|
|
127
|
+
model: slot.model,
|
|
128
|
+
messages: fullMessages,
|
|
129
|
+
temperature: options.temperature ?? 0.6,
|
|
130
|
+
maxTokens: options.maxTokens ?? 4096,
|
|
131
|
+
timeoutMs,
|
|
132
|
+
signal: abortControllers[i].signal,
|
|
133
|
+
},
|
|
134
|
+
maxRetries
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
138
|
+
const timer = setTimeout(() => reject(new Error(`Timeout after ${Math.round(timeoutMs / 1000)}s`)), timeoutMs)
|
|
139
|
+
timer.unref?.()
|
|
140
|
+
abortControllers[i].signal.addEventListener('abort', () => clearTimeout(timer), { once: true })
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
const res = await Promise.race([callPromise, timeoutPromise])
|
|
144
|
+
const text = typeof res === 'string' ? res : (res?.content || res?.text || '')
|
|
145
|
+
|
|
146
|
+
const fallbackUsage = (typeof res === 'object' && res?.usage) ? res.usage : {
|
|
147
|
+
inputTokens: Math.max(1, Math.round(fullMessages.map((m) => m.content).join('').length / 4)),
|
|
148
|
+
outputTokens: Math.max(1, Math.round(text.length / 4)),
|
|
149
|
+
}
|
|
150
|
+
const costInfo = estimateTokenCost(slot, fallbackUsage, options.prices)
|
|
151
|
+
|
|
152
|
+
finishedCount++
|
|
153
|
+
if (typeof onProgress === 'function') {
|
|
154
|
+
const costStr = costInfo.costUsd > 0 ? ` (~\$${costInfo.costUsd.toFixed(4)})` : ''
|
|
155
|
+
onProgress(`✅ *Candidate ${i + 1}/${total} (${label}) finished${costStr}*\n`)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
results[i] = {
|
|
159
|
+
index: i + 1,
|
|
160
|
+
slot,
|
|
161
|
+
label,
|
|
162
|
+
text,
|
|
163
|
+
usage: costInfo,
|
|
164
|
+
costUsd: costInfo.costUsd,
|
|
165
|
+
ok: true,
|
|
166
|
+
}
|
|
167
|
+
} catch (err) {
|
|
168
|
+
finishedCount++
|
|
169
|
+
const errMsg = err?.message || String(err)
|
|
170
|
+
console.warn(`[dsh-moa] Reference ${label} failed:`, errMsg)
|
|
171
|
+
if (typeof onProgress === 'function') {
|
|
172
|
+
onProgress(`⚠️ *Candidate ${i + 1}/${total} (${label}) error: ${errMsg}*\n`)
|
|
173
|
+
}
|
|
174
|
+
results[i] = {
|
|
175
|
+
index: i + 1,
|
|
176
|
+
slot,
|
|
177
|
+
label,
|
|
178
|
+
text: `[Model ${label} error: ${errMsg}]`,
|
|
179
|
+
usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 },
|
|
180
|
+
costUsd: 0,
|
|
181
|
+
ok: false,
|
|
182
|
+
error: errMsg,
|
|
183
|
+
}
|
|
184
|
+
} finally {
|
|
185
|
+
notifyFinished()
|
|
156
186
|
}
|
|
157
|
-
return `Reference ${i + 1} — ${r.label}:${fileSummary}\n${textContent}`
|
|
158
|
-
})
|
|
159
|
-
.join('\n\n')
|
|
160
|
-
|
|
161
|
-
const criteriaBlock = judgeCriteria && judgeCriteria.trim()
|
|
162
|
-
? `\n### 🎯 Дополнительные критерии оценки от пользователя:\n${judgeCriteria.trim()}\n`
|
|
163
|
-
: ''
|
|
164
|
-
|
|
165
|
-
return `You are the expert aggregator/judge in a Mixture of Agents (MoA) process. You evaluate solutions from multiple candidate models, judge which one is best (or how to combine their best parts), and deliver the final authoritative verdict and solution.
|
|
166
|
-
|
|
167
|
-
Original user prompt:
|
|
168
|
-
${userPrompt}
|
|
169
|
-
${criteriaBlock}
|
|
170
|
-
Reference responses from candidate models:
|
|
171
|
-
${joined}
|
|
172
|
-
|
|
173
|
-
Instructions:
|
|
174
|
-
Your response MUST be structured into three clear parts (respond in the same language as the user's prompt, e.g. Russian):
|
|
175
|
-
|
|
176
|
-
### 1. ⚖️ Вердикт судьи и анализ вариантов
|
|
177
|
-
- **Чей вариант выбран**: Чётко укажи имя модели и номер кандидата (например: "Победитель: Кандидат 1 (opencode-go:deepseek-v4-flash)" или "Выбран вариант модели Reference 1 (label)").
|
|
178
|
-
- Обязательно добавь машинный маркер выбора победителя:
|
|
179
|
-
WINNER_CANDIDATE_INDEX: <число от 1 до N>
|
|
180
|
-
- **Почему сделан этот выбор**: Подробно сравни код, архитектуру, сильные стороны, недочёты и надёжность всех кандидатов.
|
|
181
|
-
|
|
182
|
-
### 2. 📁 Созданные файлы проекта
|
|
183
|
-
- Перечисли файлы победителя, которые были перенесены в корень проекта, и их назначение.
|
|
184
|
-
|
|
185
|
-
### 3. 🚀 Инструкция по запуску и использованию
|
|
186
|
-
- Опиши, как открыть и запустить созданный проект.`
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
export function parseWinnerIndex(judgeText, defaultIndex = 1) {
|
|
190
|
-
if (!judgeText || typeof judgeText !== 'string') return defaultIndex
|
|
191
|
-
const match = /WINNER_CANDIDATE_INDEX:\s*(\d+)/i.exec(judgeText)
|
|
192
|
-
if (match) {
|
|
193
|
-
const idx = parseInt(match[1], 10)
|
|
194
|
-
if (!isNaN(idx) && idx >= 1) return idx
|
|
195
|
-
}
|
|
196
|
-
const candMatch = /(?:Кандидат|Reference)\s*(\d+)\b/i.exec(judgeText)
|
|
197
|
-
if (candMatch) {
|
|
198
|
-
const idx = parseInt(candMatch[1], 10)
|
|
199
|
-
if (!isNaN(idx) && idx >= 1) return idx
|
|
200
|
-
}
|
|
201
|
-
return defaultIndex
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
export function parseMoACommand(text, presets = []) {
|
|
205
|
-
if (typeof text !== 'string' || !text.startsWith('/moa')) {
|
|
206
|
-
return null
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
const remainder = text.slice(4).trim()
|
|
210
|
-
if (!remainder) {
|
|
211
|
-
return {
|
|
212
|
-
presetName: 'default',
|
|
213
|
-
prompt: '',
|
|
214
187
|
}
|
|
215
|
-
}
|
|
216
188
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
return {
|
|
220
|
-
presetName: matchPreset[1],
|
|
221
|
-
prompt: matchPreset[2] || '',
|
|
222
|
-
}
|
|
223
|
-
}
|
|
189
|
+
runOne()
|
|
190
|
+
})
|
|
224
191
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
192
|
+
// Straggler mitigation via Quorum + Grace Period
|
|
193
|
+
if (quorumEnabled && total >= 3) {
|
|
194
|
+
const quorumTarget = Math.max(2, Math.min(total - 1, Math.ceil(total * 0.6)))
|
|
195
|
+
let graceTimer = null
|
|
196
|
+
|
|
197
|
+
await new Promise((resolve) => {
|
|
198
|
+
const checkQuorum = () => {
|
|
199
|
+
if (finishedCount >= total) {
|
|
200
|
+
if (graceTimer) clearTimeout(graceTimer)
|
|
201
|
+
resolve()
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (finishedCount >= quorumTarget && !graceTimer) {
|
|
206
|
+
if (typeof onProgress === 'function') {
|
|
207
|
+
onProgress(`⏳ *Quorum reached (${finishedCount}/${total}). Starting ${Math.round(gracePeriodMs / 1000)}s grace period for stragglers...*\n`)
|
|
208
|
+
}
|
|
209
|
+
graceTimer = setTimeout(() => {
|
|
210
|
+
if (typeof onProgress === 'function' && finishedCount < total) {
|
|
211
|
+
onProgress(`⏩ *Grace period expired. Proceeding with ${finishedCount}/${total} ready candidates.*\n`)
|
|
212
|
+
}
|
|
213
|
+
for (let k = 0; k < total; k++) {
|
|
214
|
+
if (!results[k]) {
|
|
215
|
+
try { abortControllers[k].abort(new Error('Quorum grace period timed out')) } catch {}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
resolve()
|
|
219
|
+
}, gracePeriodMs)
|
|
220
|
+
graceTimer.unref?.()
|
|
221
|
+
}
|
|
233
222
|
}
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
223
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
}
|
|
241
|
-
}
|
|
224
|
+
onTaskFinished = checkQuorum
|
|
225
|
+
checkQuorum()
|
|
226
|
+
})
|
|
242
227
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
228
|
+
for (let i = 0; i < total; i++) {
|
|
229
|
+
if (!results[i]) {
|
|
230
|
+
const slot = references[i]
|
|
231
|
+
const label = slotLabel(slot)
|
|
232
|
+
results[i] = {
|
|
233
|
+
index: i + 1,
|
|
234
|
+
slot,
|
|
235
|
+
label,
|
|
236
|
+
text: `[Model ${label} timed out (quorum grace period exceeded)]`,
|
|
237
|
+
usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 },
|
|
238
|
+
costUsd: 0,
|
|
239
|
+
ok: false,
|
|
240
|
+
error: 'Quorum grace period timed out',
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return results
|
|
249
245
|
}
|
|
250
246
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
const tasks = references.map(async (slot, i) => {
|
|
257
|
-
const label = slotLabel(slot)
|
|
258
|
-
if (typeof onProgress === 'function') {
|
|
259
|
-
onProgress(`⚡ *Кандидат ${i + 1} (${label}) начал генерацию...*\n`)
|
|
247
|
+
// Standard mode: wait for all tasks to complete
|
|
248
|
+
await new Promise((resolve) => {
|
|
249
|
+
const checkAll = () => {
|
|
250
|
+
if (finishedCount >= total) resolve()
|
|
260
251
|
}
|
|
252
|
+
onTaskFinished = checkAll
|
|
253
|
+
checkAll()
|
|
254
|
+
})
|
|
261
255
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
provider: slot.provider,
|
|
265
|
-
model: slot.model,
|
|
266
|
-
messages: fullMessages,
|
|
267
|
-
temperature: options.temperature ?? 0.6,
|
|
268
|
-
maxTokens: options.maxTokens ?? 4096,
|
|
269
|
-
})
|
|
270
|
-
|
|
271
|
-
const timeoutPromise = new Promise((_, reject) => {
|
|
272
|
-
const timer = setTimeout(() => reject(new Error(`Timeout after ${timeoutMs / 1000}s`)), timeoutMs)
|
|
273
|
-
timer.unref?.()
|
|
274
|
-
})
|
|
275
|
-
|
|
276
|
-
const res = await Promise.race([callPromise, timeoutPromise])
|
|
277
|
-
const text = typeof res === 'string' ? res : (res?.content || res?.text || '')
|
|
278
|
-
|
|
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)),
|
|
282
|
-
}
|
|
283
|
-
const costInfo = estimateTokenCost(slot, fallbackUsage)
|
|
256
|
+
return results
|
|
257
|
+
}
|
|
284
258
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
259
|
+
function candidatesForHistory(referenceOutputs) {
|
|
260
|
+
return (referenceOutputs || []).map((r) => ({
|
|
261
|
+
provider: r.slot?.provider || '',
|
|
262
|
+
model: r.slot?.model || '',
|
|
263
|
+
files: (r.files || []).map((f) => f.relativePath),
|
|
264
|
+
usage: r.usage || { inputTokens: 0, outputTokens: 0 },
|
|
265
|
+
costUsd: r.costUsd || 0,
|
|
266
|
+
}))
|
|
267
|
+
}
|
|
289
268
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
const errMsg = err?.message || String(err)
|
|
301
|
-
console.warn(`[dsh-moa] Reference ${label} failed:`, errMsg)
|
|
302
|
-
if (typeof onProgress === 'function') {
|
|
303
|
-
onProgress(`⚠️ *Кандидат ${i + 1} (${label}) ошибка: ${errMsg}*\n`)
|
|
304
|
-
}
|
|
305
|
-
return {
|
|
306
|
-
index: i + 1,
|
|
307
|
-
slot,
|
|
308
|
-
label,
|
|
309
|
-
text: `[Ошибка модели ${label}: ${errMsg}]`,
|
|
310
|
-
usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 },
|
|
311
|
-
costUsd: 0,
|
|
312
|
-
ok: false,
|
|
313
|
-
error: errMsg,
|
|
314
|
-
}
|
|
269
|
+
async function createPromotedPreview(liveCanvas, cwd, promotedFiles, referenceOutputs) {
|
|
270
|
+
if (!liveCanvas || typeof liveCanvas.createPreviewFromContent !== 'function') return null
|
|
271
|
+
const htmlRel = (promotedFiles || []).find((f) => f.endsWith('.html') || f.endsWith('.htm'))
|
|
272
|
+
if (!htmlRel) return null
|
|
273
|
+
let content = null
|
|
274
|
+
for (const r of referenceOutputs || []) {
|
|
275
|
+
const block = (r?.files || []).find((f) => f.relativePath === htmlRel)
|
|
276
|
+
if (block?.content) {
|
|
277
|
+
content = block.content
|
|
278
|
+
break
|
|
315
279
|
}
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
|
|
280
|
+
}
|
|
281
|
+
if (!content) return null
|
|
282
|
+
try {
|
|
283
|
+
return await liveCanvas.createPreviewFromContent({ content, title: htmlRel, filePath: path.join(cwd, htmlRel) })
|
|
284
|
+
} catch {
|
|
285
|
+
return null
|
|
286
|
+
}
|
|
319
287
|
}
|
|
320
288
|
|
|
321
289
|
/**
|
|
322
|
-
*
|
|
290
|
+
* Executes the full Mixture of Agents pipeline.
|
|
323
291
|
*/
|
|
324
292
|
export async function runMoAPipeline({
|
|
325
293
|
userPrompt,
|
|
@@ -327,68 +295,85 @@ export async function runMoAPipeline({
|
|
|
327
295
|
preset,
|
|
328
296
|
callLlm,
|
|
329
297
|
cwd = process.cwd(),
|
|
330
|
-
|
|
298
|
+
prices = {},
|
|
299
|
+
onProgress = null,
|
|
300
|
+
historyFilePath = null,
|
|
301
|
+
liveCanvas = null,
|
|
302
|
+
onStreamDelta = null,
|
|
331
303
|
}) {
|
|
332
304
|
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')
|
|
348
|
-
}
|
|
349
|
-
const projectContext = await collectProjectContext(cwd)
|
|
350
|
-
const isRefinement = isRefinementTask(userPrompt, projectContext.files)
|
|
351
|
-
|
|
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
|
|
357
|
-
let candidateSystemPrompt = REFERENCE_SYSTEM_PROMPT
|
|
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.`
|
|
362
|
-
}
|
|
363
305
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
306
|
+
// 1. Resolve configurations
|
|
307
|
+
const referenceModels = Array.isArray(preset?.reference_models) && preset.reference_models.length > 0
|
|
308
|
+
? preset.reference_models
|
|
309
|
+
: [{ provider: 'opencode-go', model: 'deepseek-v4-flash' }]
|
|
310
|
+
|
|
311
|
+
const primaryJudge = preset?.aggregator?.provider && preset?.aggregator?.model
|
|
312
|
+
? preset.aggregator
|
|
313
|
+
: { provider: 'codex', model: 'gpt-5.6-sol' }
|
|
314
|
+
|
|
315
|
+
const fallbackJudges = Array.isArray(preset?.aggregator_fallbacks) ? preset.aggregator_fallbacks : []
|
|
316
|
+
const judgesChain = [primaryJudge, ...fallbackJudges]
|
|
317
|
+
|
|
318
|
+
const refTemp = typeof preset?.reference_temperature === 'number' ? preset.reference_temperature : 0.6
|
|
319
|
+
const aggTemp = typeof preset?.aggregator_temperature === 'number' ? preset.aggregator_temperature : 0.4
|
|
320
|
+
const maxTokens = typeof preset?.max_tokens === 'number' ? preset.max_tokens : 4096
|
|
321
|
+
const judgeCriteria = preset?.judge_criteria || ''
|
|
322
|
+
const isFastMode = referenceModels.length === 1 && !preset?.curator_synthesis
|
|
323
|
+
const isCuratorSynthesis = Boolean(preset?.curator_synthesis)
|
|
324
|
+
const isStreamAggregator = preset?.stream_aggregator !== false
|
|
325
|
+
const isQuorumEnabled = Boolean(preset?.quorum_enabled)
|
|
326
|
+
const gracePeriodSec = typeof preset?.grace_period_sec === 'number' ? preset.grace_period_sec : 10
|
|
327
|
+
const candidateRetries = 1
|
|
328
|
+
const refTimeoutSec = typeof preset?.reference_timeout_sec === 'number' ? preset.reference_timeout_sec : 60
|
|
329
|
+
const aggTimeoutSec = typeof preset?.aggregator_timeout_sec === 'number' ? preset.aggregator_timeout_sec : 180
|
|
330
|
+
|
|
331
|
+
// 2. Collect project context for refinement tasks
|
|
332
|
+
const isRefinement = isRefinementTask(userPrompt)
|
|
333
|
+
let projectContext = ''
|
|
334
|
+
if (isRefinement) {
|
|
335
|
+
if (typeof onProgress === 'function') {
|
|
336
|
+
onProgress('🔍 *Reading project files for refinement context...*\n')
|
|
337
|
+
}
|
|
338
|
+
projectContext = await collectProjectContext(cwd, 16000)
|
|
367
339
|
}
|
|
368
340
|
|
|
369
|
-
|
|
341
|
+
// 3. Build prompts & evaluate broad questionnaire needs
|
|
342
|
+
const candidateSystemPrompt = projectContext
|
|
343
|
+
? `${REFERENCE_SYSTEM_PROMPT}\n\n### Current Project Files & Context:\n${projectContext}`
|
|
344
|
+
: REFERENCE_SYSTEM_PROMPT
|
|
370
345
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
}
|
|
346
|
+
const askClarifyingQuestions = preset?.ask_clarifying_questions !== false
|
|
347
|
+
const needsQuestions = askClarifyingQuestions && isBroadPromptRequiringQuestions(userPrompt, messages)
|
|
348
|
+
|
|
349
|
+
const enrichedMessages = [...messages, { role: 'user', content: userPrompt }]
|
|
375
350
|
|
|
351
|
+
// 4. Parallel fan-out to candidate models with quorum & transient retry
|
|
376
352
|
const referenceOutputs = await runReferencesParallel(
|
|
377
353
|
referenceModels,
|
|
378
354
|
enrichedMessages,
|
|
379
|
-
{
|
|
355
|
+
{
|
|
356
|
+
systemPrompt: candidateSystemPrompt,
|
|
357
|
+
temperature: refTemp,
|
|
358
|
+
maxTokens,
|
|
359
|
+
prices,
|
|
360
|
+
timeoutSec: refTimeoutSec,
|
|
361
|
+
quorumEnabled: isQuorumEnabled,
|
|
362
|
+
gracePeriodSec,
|
|
363
|
+
maxRetries: candidateRetries,
|
|
364
|
+
},
|
|
380
365
|
callLlm,
|
|
381
366
|
onProgress
|
|
382
367
|
)
|
|
383
368
|
|
|
384
|
-
// Fail fast if all candidates failed
|
|
369
|
+
// Fail fast if all candidates failed
|
|
385
370
|
const successfulRefs = referenceOutputs.filter((r) => r.ok)
|
|
386
371
|
if (successfulRefs.length === 0) {
|
|
387
372
|
const reasons = referenceOutputs.map((r) => `${r.label}: ${r.error || 'unknown error'}`).join('; ')
|
|
388
373
|
return {
|
|
389
374
|
kind: 'failure',
|
|
390
|
-
content: `⚠️
|
|
391
|
-
aggregator:
|
|
375
|
+
content: `⚠️ All advisor models (${referenceOutputs.length}) failed: ${reasons}`,
|
|
376
|
+
aggregator: slotLabel(primaryJudge),
|
|
392
377
|
references: referenceOutputs,
|
|
393
378
|
presetName: preset?.name || 'default',
|
|
394
379
|
isRefinement,
|
|
@@ -406,7 +391,7 @@ export async function runMoAPipeline({
|
|
|
406
391
|
}
|
|
407
392
|
}
|
|
408
393
|
|
|
409
|
-
// 5. Extract file blocks & write candidate workspaces
|
|
394
|
+
// 5. Extract file blocks & write candidate workspaces
|
|
410
395
|
for (let i = 0; i < referenceOutputs.length; i++) {
|
|
411
396
|
const ref = referenceOutputs[i]
|
|
412
397
|
if (!ref.ok) continue
|
|
@@ -417,49 +402,112 @@ export async function runMoAPipeline({
|
|
|
417
402
|
}
|
|
418
403
|
}
|
|
419
404
|
|
|
420
|
-
// 6. Questionnaire synthesis branch
|
|
405
|
+
// 6. Questionnaire synthesis branch
|
|
421
406
|
if (needsQuestions) {
|
|
422
407
|
if (typeof onProgress === 'function') {
|
|
423
|
-
onProgress('📋
|
|
408
|
+
onProgress('📋 *Judge synthesizes the clarification questionnaire...*\n')
|
|
424
409
|
}
|
|
425
410
|
const questionPrompt = buildQuestionSynthesisPrompt(userPrompt, referenceOutputs)
|
|
426
411
|
let questionsContent = ''
|
|
412
|
+
let qUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 }
|
|
427
413
|
try {
|
|
428
|
-
const qRes = await callLlm
|
|
429
|
-
provider:
|
|
430
|
-
model:
|
|
414
|
+
const qRes = await callWithTransientRetry(callLlm, {
|
|
415
|
+
provider: primaryJudge.provider,
|
|
416
|
+
model: primaryJudge.model,
|
|
431
417
|
messages: [{ role: 'user', content: questionPrompt }],
|
|
432
418
|
temperature: 0.3,
|
|
433
419
|
maxTokens: 2048,
|
|
434
|
-
|
|
420
|
+
timeoutMs: aggTimeoutSec * 1000,
|
|
421
|
+
}, 1)
|
|
435
422
|
questionsContent = typeof qRes === 'string' ? qRes : (qRes?.content || qRes?.text || '')
|
|
436
|
-
|
|
437
|
-
|
|
423
|
+
const qFallbackUsage = (typeof qRes === 'object' && qRes?.usage) ? qRes.usage : {
|
|
424
|
+
inputTokens: Math.max(1, Math.round(questionPrompt.length / 4)),
|
|
425
|
+
outputTokens: Math.max(1, Math.round(questionsContent.length / 4)),
|
|
426
|
+
}
|
|
427
|
+
qUsage = estimateTokenCost(primaryJudge, qFallbackUsage, prices)
|
|
428
|
+
} catch (err) {
|
|
429
|
+
console.warn('[dsh-moa] Questionnaire synthesis failed, proceeding with fallback questions:', err)
|
|
430
|
+
questionsContent = `### Уточнение требований по задаче: "${userPrompt}"\n\n` +
|
|
431
|
+
'1. **Формат решения**: однофайловый HTML/JS или многомодульный проект?\n' +
|
|
432
|
+
'2. **Стиль и визуальное оформление**: минимализм, темная тема или нейтральный интерфейс?\n' +
|
|
433
|
+
'3. **Функциональные приоритеты**: базовый MVP или расширенная реализация?\n\n' +
|
|
434
|
+
'*Ответьте кратко (например, "1, 2") или доверьтесь выбору по умолчанию.*'
|
|
438
435
|
}
|
|
439
436
|
|
|
437
|
+
await cleanMoaWorkspaces(cwd)
|
|
438
|
+
const totalTokens = referenceOutputs.reduce((acc, r) => acc + (r.usage?.totalTokens || 0), 0) + qUsage.totalTokens
|
|
439
|
+
const totalCostUsd = Number((referenceOutputs.reduce((acc, r) => acc + (r.costUsd || 0), 0) + qUsage.costUsd).toFixed(5))
|
|
440
|
+
const durationMs = Date.now() - startTime
|
|
441
|
+
|
|
442
|
+
try {
|
|
443
|
+
recordMoaRun({
|
|
444
|
+
prompt: userPrompt,
|
|
445
|
+
preset: preset?.name || 'default',
|
|
446
|
+
isRefinement,
|
|
447
|
+
candidates: candidatesForHistory(referenceOutputs),
|
|
448
|
+
aggregator: { provider: primaryJudge.provider, model: primaryJudge.model, usage: qUsage, costUsd: qUsage.costUsd },
|
|
449
|
+
winnerIndex: -1,
|
|
450
|
+
winnerModel: 'none',
|
|
451
|
+
promotedFiles: [],
|
|
452
|
+
totalTokens,
|
|
453
|
+
totalCostUsd,
|
|
454
|
+
durationMs,
|
|
455
|
+
}, historyFilePath || undefined)
|
|
456
|
+
} catch {}
|
|
457
|
+
|
|
440
458
|
return {
|
|
441
459
|
kind: 'questions',
|
|
442
460
|
content: questionsContent,
|
|
461
|
+
aggregator: slotLabel(primaryJudge),
|
|
443
462
|
references: referenceOutputs,
|
|
444
463
|
presetName: preset?.name || 'default',
|
|
445
|
-
isRefinement
|
|
464
|
+
isRefinement,
|
|
465
|
+
isFastMode,
|
|
466
|
+
winningIndex: 0,
|
|
467
|
+
winnerModel: 'none',
|
|
468
|
+
promotedFiles: [],
|
|
469
|
+
usage: {
|
|
470
|
+
totalTokens,
|
|
471
|
+
totalCostUsd,
|
|
472
|
+
candidates: referenceOutputs.map((r) => ({ label: r.label, usage: r.usage, costUsd: r.costUsd })),
|
|
473
|
+
aggregator: qUsage,
|
|
474
|
+
},
|
|
475
|
+
durationMs,
|
|
446
476
|
}
|
|
447
477
|
}
|
|
448
478
|
|
|
449
|
-
// 7. Fast
|
|
479
|
+
// 7. Fast Mode (1 candidate, bypass judge)
|
|
450
480
|
if (isFastMode) {
|
|
451
|
-
const single =
|
|
481
|
+
const single = successfulRefs[0]
|
|
452
482
|
let promotedFiles = []
|
|
453
|
-
if (single.files
|
|
454
|
-
promotedFiles = await promoteCandidateWorkspace(cwd,
|
|
483
|
+
if (single.files?.length > 0) {
|
|
484
|
+
promotedFiles = await promoteCandidateWorkspace(cwd, single.index)
|
|
455
485
|
} else {
|
|
456
486
|
await cleanMoaWorkspaces(cwd)
|
|
457
487
|
}
|
|
458
488
|
|
|
489
|
+
const livePreview = await createPromotedPreview(liveCanvas, cwd, promotedFiles, referenceOutputs)
|
|
459
490
|
const totalTokens = single.usage?.totalTokens || 0
|
|
460
|
-
const totalCostUsd = single.costUsd || 0
|
|
491
|
+
const totalCostUsd = Number((single.costUsd || 0).toFixed(5))
|
|
461
492
|
const durationMs = Date.now() - startTime
|
|
462
493
|
|
|
494
|
+
try {
|
|
495
|
+
recordMoaRun({
|
|
496
|
+
prompt: userPrompt,
|
|
497
|
+
preset: preset?.name || 'default',
|
|
498
|
+
isRefinement,
|
|
499
|
+
isFastMode: true,
|
|
500
|
+
candidates: candidatesForHistory(referenceOutputs),
|
|
501
|
+
aggregator: null,
|
|
502
|
+
winnerIndex: single.index,
|
|
503
|
+
winnerModel: single.label,
|
|
504
|
+
promotedFiles,
|
|
505
|
+
totalTokens,
|
|
506
|
+
totalCostUsd,
|
|
507
|
+
durationMs,
|
|
508
|
+
}, historyFilePath || undefined)
|
|
509
|
+
} catch {}
|
|
510
|
+
|
|
463
511
|
return {
|
|
464
512
|
kind: 'synthesis',
|
|
465
513
|
content: single.text,
|
|
@@ -468,52 +516,94 @@ export async function runMoAPipeline({
|
|
|
468
516
|
presetName: preset?.name || 'default',
|
|
469
517
|
isRefinement,
|
|
470
518
|
isFastMode: true,
|
|
471
|
-
winningIndex:
|
|
519
|
+
winningIndex: single.index,
|
|
472
520
|
winnerModel: single.label,
|
|
473
521
|
promotedFiles,
|
|
522
|
+
...(livePreview ? { liveCanvas: livePreview } : {}),
|
|
474
523
|
usage: {
|
|
475
524
|
totalTokens,
|
|
476
525
|
totalCostUsd,
|
|
477
|
-
candidates:
|
|
526
|
+
candidates: referenceOutputs.map((r) => ({ label: r.label, usage: r.usage, costUsd: r.costUsd })),
|
|
478
527
|
aggregator: { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 },
|
|
479
528
|
},
|
|
480
529
|
durationMs,
|
|
481
530
|
}
|
|
482
531
|
}
|
|
483
532
|
|
|
484
|
-
// 8.
|
|
485
|
-
|
|
486
|
-
onProgress(`⚖️ *Ведущая модель (${aggLabel}) оценивает варианты и синтезирует решение...*\n`)
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
const synthesisPrompt = buildSynthesisPrompt(userPrompt, referenceOutputs, judgeCriteria)
|
|
533
|
+
// 8. Synthesis phase via primary judge or fallback chain
|
|
534
|
+
const synthesisPrompt = buildSynthesisPrompt(userPrompt, referenceOutputs, judgeCriteria, { curatorSynthesis: isCuratorSynthesis })
|
|
490
535
|
let synthesizedText = ''
|
|
491
536
|
let aggUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 }
|
|
537
|
+
let chosenJudge = primaryJudge
|
|
538
|
+
let judgeSuccess = false
|
|
539
|
+
let lastJudgeError = null
|
|
492
540
|
|
|
493
|
-
|
|
494
|
-
const
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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
|
+
for (let jIdx = 0; jIdx < judgesChain.length; jIdx++) {
|
|
542
|
+
const currentJudge = judgesChain[jIdx]
|
|
543
|
+
const currentLabel = slotLabel(currentJudge)
|
|
544
|
+
|
|
545
|
+
if (typeof onProgress === 'function') {
|
|
546
|
+
const modeTitle = isCuratorSynthesis ? 'Curator' : 'Judge'
|
|
547
|
+
const fallbackBadge = jIdx > 0 ? ` (Fallback #${jIdx})` : ''
|
|
548
|
+
onProgress(`⚖️ *${modeTitle} (${currentLabel}${fallbackBadge}) synthesizing solution...*\n`)
|
|
505
549
|
}
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
550
|
+
|
|
551
|
+
try {
|
|
552
|
+
const aggRes = await callWithTransientRetry(callLlm, {
|
|
553
|
+
provider: currentJudge.provider,
|
|
554
|
+
model: currentJudge.model,
|
|
555
|
+
messages: [{ role: 'user', content: synthesisPrompt }],
|
|
556
|
+
temperature: aggTemp,
|
|
557
|
+
maxTokens,
|
|
558
|
+
timeoutMs: aggTimeoutSec * 1000,
|
|
559
|
+
onStreamDelta: (delta) => {
|
|
560
|
+
if (isStreamAggregator && typeof onStreamDelta === 'function') {
|
|
561
|
+
onStreamDelta(delta)
|
|
562
|
+
}
|
|
563
|
+
},
|
|
564
|
+
}, 0)
|
|
565
|
+
|
|
566
|
+
synthesizedText = typeof aggRes === 'string' ? aggRes : (aggRes?.content || aggRes?.text || '')
|
|
567
|
+
|
|
568
|
+
const aggFallbackUsage = (typeof aggRes === 'object' && aggRes?.usage) ? aggRes.usage : {
|
|
569
|
+
inputTokens: Math.max(1, Math.round(synthesisPrompt.length / 4)),
|
|
570
|
+
outputTokens: Math.max(1, Math.round(synthesizedText.length / 4)),
|
|
571
|
+
}
|
|
572
|
+
aggUsage = estimateTokenCost(currentJudge, aggFallbackUsage, prices)
|
|
573
|
+
chosenJudge = currentJudge
|
|
574
|
+
judgeSuccess = true
|
|
575
|
+
break
|
|
576
|
+
} catch (err) {
|
|
577
|
+
lastJudgeError = err
|
|
578
|
+
console.warn(`[dsh-moa] Judge ${currentLabel} failed:`, err)
|
|
579
|
+
if (typeof onProgress === 'function') {
|
|
580
|
+
const nextJudge = judgesChain[jIdx + 1]
|
|
581
|
+
const nextHint = nextJudge ? ` Trying fallback ${slotLabel(nextJudge)}...` : ''
|
|
582
|
+
onProgress(`⚠️ *Judge ${currentLabel} failed: ${err?.message || err}.${nextHint}*\n`)
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
if (!judgeSuccess) {
|
|
588
|
+
const firstErr = lastJudgeError ? (lastJudgeError.message || String(lastJudgeError)) : 'unknown error'
|
|
589
|
+
synthesizedText = `⚠️ *[Aggregator error: ${firstErr}. Fallback candidate outputs:]*\n\n` +
|
|
590
|
+
successfulRefs.map((r, i) => `### Candidate ${i + 1} (${r.label})\n${r.text}`).join('\n\n')
|
|
511
591
|
}
|
|
512
592
|
|
|
513
|
-
// 9. Evaluate winner & promote files
|
|
514
|
-
const winningIndex = parseWinnerIndex(synthesizedText, 1)
|
|
593
|
+
// 9. Evaluate winner & promote files
|
|
594
|
+
const winningIndex = parseWinnerIndex(synthesizedText, 1, referenceOutputs.length)
|
|
595
|
+
const recommendedAssembler = isCuratorSynthesis
|
|
596
|
+
? parseRecommendedAssembler(synthesizedText, winningIndex, referenceOutputs.length)
|
|
597
|
+
: null
|
|
598
|
+
|
|
599
|
+
// Check if aggregator synthesized unified file blocks directly
|
|
600
|
+
const synthesizedFiles = extractFileBlocks(synthesizedText)
|
|
515
601
|
let promotedFiles = []
|
|
516
|
-
|
|
602
|
+
|
|
603
|
+
if (synthesizedFiles.length > 0) {
|
|
604
|
+
await writeCandidateWorkspace(cwd, 'curator-synthesis', synthesizedFiles)
|
|
605
|
+
promotedFiles = await promoteCandidateWorkspace(cwd, 'curator-synthesis')
|
|
606
|
+
} else if (referenceOutputs[winningIndex - 1]?.files?.length > 0) {
|
|
517
607
|
promotedFiles = await promoteCandidateWorkspace(cwd, winningIndex)
|
|
518
608
|
} else if (successfulRefs[0]?.files?.length > 0) {
|
|
519
609
|
const fallbackIdx = successfulRefs[0].index
|
|
@@ -522,16 +612,7 @@ export async function runMoAPipeline({
|
|
|
522
612
|
await cleanMoaWorkspaces(cwd)
|
|
523
613
|
}
|
|
524
614
|
|
|
525
|
-
|
|
526
|
-
let liveCanvas = null
|
|
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',
|
|
533
|
-
}
|
|
534
|
-
}
|
|
615
|
+
const livePreview = await createPromotedPreview(liveCanvas, cwd, promotedFiles, referenceOutputs)
|
|
535
616
|
|
|
536
617
|
const totalTokens = referenceOutputs.reduce((acc, r) => acc + (r.usage?.totalTokens || 0), 0) + aggUsage.totalTokens
|
|
537
618
|
const totalCostUsd = Number((referenceOutputs.reduce((acc, r) => acc + (r.costUsd || 0), 0) + aggUsage.costUsd).toFixed(5))
|
|
@@ -539,22 +620,23 @@ export async function runMoAPipeline({
|
|
|
539
620
|
|
|
540
621
|
const winningRef = referenceOutputs[winningIndex - 1]
|
|
541
622
|
const winnerModel = winningRef?.label || slotLabel(referenceModels[0])
|
|
623
|
+
const finalAggLabel = slotLabel(chosenJudge)
|
|
542
624
|
|
|
543
|
-
// Record run to history
|
|
625
|
+
// Record run to history
|
|
544
626
|
try {
|
|
545
627
|
recordMoaRun({
|
|
546
628
|
prompt: userPrompt,
|
|
547
629
|
preset: preset?.name || 'default',
|
|
548
630
|
isRefinement,
|
|
549
|
-
candidates: referenceOutputs,
|
|
550
|
-
aggregator:
|
|
631
|
+
candidates: candidatesForHistory(referenceOutputs),
|
|
632
|
+
aggregator: { provider: chosenJudge.provider, model: chosenJudge.model, usage: aggUsage, costUsd: aggUsage.costUsd },
|
|
551
633
|
winnerIndex: winningIndex,
|
|
552
634
|
winnerModel,
|
|
553
635
|
promotedFiles,
|
|
554
636
|
totalTokens,
|
|
555
637
|
totalCostUsd,
|
|
556
638
|
durationMs,
|
|
557
|
-
})
|
|
639
|
+
}, historyFilePath || undefined)
|
|
558
640
|
} catch (histErr) {
|
|
559
641
|
console.warn('[dsh-moa] Failed to record run in history:', histErr)
|
|
560
642
|
}
|
|
@@ -562,19 +644,21 @@ export async function runMoAPipeline({
|
|
|
562
644
|
return {
|
|
563
645
|
kind: 'synthesis',
|
|
564
646
|
content: synthesizedText,
|
|
565
|
-
aggregator: isFastMode ? 'Fast Mode (Direct)' :
|
|
647
|
+
aggregator: isFastMode ? 'Fast Mode (Direct)' : finalAggLabel,
|
|
566
648
|
references: referenceOutputs,
|
|
567
649
|
presetName: preset?.name || 'default',
|
|
568
650
|
isRefinement,
|
|
569
651
|
isFastMode,
|
|
652
|
+
isCuratorSynthesis,
|
|
653
|
+
recommendedAssembler,
|
|
570
654
|
winningIndex,
|
|
571
655
|
winnerModel,
|
|
572
656
|
promotedFiles,
|
|
573
|
-
liveCanvas,
|
|
657
|
+
...(livePreview ? { liveCanvas: livePreview } : {}),
|
|
574
658
|
usage: {
|
|
575
659
|
totalTokens,
|
|
576
660
|
totalCostUsd,
|
|
577
|
-
candidates: referenceOutputs.map(r => ({ label: r.label, usage: r.usage, costUsd: r.costUsd })),
|
|
661
|
+
candidates: referenceOutputs.map((r) => ({ label: r.label, usage: r.usage, costUsd: r.costUsd })),
|
|
578
662
|
aggregator: aggUsage,
|
|
579
663
|
},
|
|
580
664
|
durationMs,
|
|
@@ -582,103 +666,14 @@ export async function runMoAPipeline({
|
|
|
582
666
|
}
|
|
583
667
|
|
|
584
668
|
/**
|
|
585
|
-
*
|
|
669
|
+
* Streams a full MoA turn into chat with live aggregator tokens and progress feedback.
|
|
586
670
|
*/
|
|
587
|
-
export function
|
|
588
|
-
if (!text || typeof text !== 'string') return ''
|
|
589
|
-
return text.replace(/```([a-zA-Z0-9_\-\.\/]*)\s*([\w\.\/\-]+\.[a-zA-Z0-9]+)?\n([\s\S]*?)```/g, (match, lang, fileTag, code) => {
|
|
590
|
-
const lines = code.trim().split('\n')
|
|
591
|
-
if (lines.length <= 3 && !/html|jsx|tsx|vue|svelte|css|js|ts/i.test(lang)) {
|
|
592
|
-
return match
|
|
593
|
-
}
|
|
594
|
-
const fileHint = fileTag || (code.match(/^\s*(?:\/\/|#|<!--|\/\*)\s*(?:file|filepath|path):\s*([^\s*]+)/im)?.[1])
|
|
595
|
-
const label = fileHint ? `файл \`${fileHint}\`` : (lang ? `код \`${lang}\`` : 'код')
|
|
596
|
-
return `\n> 📄 *[${label} — ${lines.length} строк сохранены на диск]*\n`
|
|
597
|
-
})
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
export function formatMoAResponse({ moaResult, presetName }) {
|
|
601
|
-
const parts = []
|
|
602
|
-
const pName = presetName || moaResult?.presetName || 'default'
|
|
603
|
-
const judge = moaResult?.aggregator || 'unknown'
|
|
604
|
-
const refs = moaResult?.references || []
|
|
605
|
-
|
|
606
|
-
if (moaResult?.kind === 'questions') {
|
|
607
|
-
parts.push('## 🧠 Mixture of Agents — Уточнение требований')
|
|
608
|
-
parts.push(`*Судья (${judge}) и советники проанализировали задачу:*\n`)
|
|
609
|
-
parts.push(moaResult.content)
|
|
610
|
-
return parts.join('\n')
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
if (moaResult?.kind === 'failure') {
|
|
614
|
-
parts.push('## ⚠️ Mixture of Agents — Сбой выполнения')
|
|
615
|
-
parts.push(moaResult.content)
|
|
616
|
-
return parts.join('\n')
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
const modeBadge = moaResult?.isFastMode ? '⚡ Fast Mode' : `Судья: ${judge}`
|
|
620
|
-
parts.push(`## 🧠 Mixture of Agents (Пресет: ${pName} | ${modeBadge})`)
|
|
621
|
-
parts.push('')
|
|
622
|
-
|
|
623
|
-
if (moaResult?.isRefinement) {
|
|
624
|
-
parts.push('> 🔄 **Режим**: Итеративная доработка проекта (Refinement)')
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
const hasPromoted = moaResult?.promotedFiles && moaResult.promotedFiles.length > 0
|
|
628
|
-
if (hasPromoted) {
|
|
629
|
-
parts.push(`> 📦 **Созданы файлы в проекте**: \`${moaResult.promotedFiles.join('`, `')}\``)
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
if (moaResult?.liveCanvas?.previewUrl) {
|
|
633
|
-
parts.push(`> 🎨 **Live Canvas**: [🚀 Открыть ${moaResult.liveCanvas.title || 'превью'} в Live Canvas](${moaResult.liveCanvas.previewUrl}) | [↗ Открыть в новой вкладке](${moaResult.liveCanvas.previewUrl})`)
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
// Cost tracking card (#10)
|
|
637
|
-
if (moaResult?.usage) {
|
|
638
|
-
const u = moaResult.usage
|
|
639
|
-
const costStr = u.totalCostUsd > 0 ? `~\$${u.totalCostUsd.toFixed(4)}` : 'Бесплатно'
|
|
640
|
-
const tokStr = u.totalTokens >= 1000 ? `${(u.totalTokens / 1000).toFixed(1)}k` : `${u.totalTokens}`
|
|
641
|
-
parts.push(`> 💰 **Стоимость запуска**: ${costStr} (всего ${tokStr} токенов)`)
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
parts.push('')
|
|
645
|
-
|
|
646
|
-
if (!moaResult?.isFastMode) {
|
|
647
|
-
parts.push(`### ⚖️ Вердикт судьи и итоговое решение (Синтез: ${judge})`)
|
|
648
|
-
parts.push('')
|
|
649
|
-
const cleanJudgeContent = hasPromoted
|
|
650
|
-
? stripOrSummarizeCode(moaResult?.content || '')
|
|
651
|
-
: (moaResult?.content || '(нет ответа)')
|
|
652
|
-
parts.push(cleanJudgeContent)
|
|
653
|
-
parts.push('')
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
if (Array.isArray(refs) && refs.length > 0) {
|
|
657
|
-
const title = moaResult?.isFastMode ? '### 🚀 Результат генерации кандидата:' : `### 👥 Ответы моделей-советников (${refs.length}):`
|
|
658
|
-
parts.push(title)
|
|
659
|
-
parts.push('')
|
|
660
|
-
refs.forEach((ref, i) => {
|
|
661
|
-
const statusIcon = ref.ok ? '✅' : '⚠️'
|
|
662
|
-
const fileBadge = ref.files?.length ? ` (${ref.files.length} файл(ов))` : ''
|
|
663
|
-
const costBadge = ref.costUsd > 0 ? ` [~\$${ref.costUsd.toFixed(4)}]` : ''
|
|
664
|
-
parts.push(`#### ${statusIcon} Модель ${i + 1}: ${ref.label}${fileBadge}${costBadge}`)
|
|
665
|
-
parts.push('')
|
|
666
|
-
parts.push(stripOrSummarizeCode(ref.text))
|
|
667
|
-
parts.push('')
|
|
668
|
-
parts.push('---')
|
|
669
|
-
parts.push('')
|
|
670
|
-
})
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
return parts.join('\n')
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
export async function* streamMoATurn({ targetPreset, userPrompt, messages, callLlm, cwd }, options = {}) {
|
|
671
|
+
export async function* streamMoATurn({ targetPreset, userPrompt, messages, callLlm, cwd, prices, historyFilePath, liveCanvas }, options = {}) {
|
|
677
672
|
const signal = options?.signal
|
|
678
673
|
if (signal?.aborted) return
|
|
679
674
|
|
|
680
675
|
yield { type: 'block-start', index: 0, blockType: 'text' }
|
|
681
|
-
yield { type: 'text-delta', index: 0, text: '🧠 *Mixture of Agents
|
|
676
|
+
yield { type: 'text-delta', index: 0, text: '🧠 *Mixture of Agents started...*\n\n' }
|
|
682
677
|
|
|
683
678
|
// Async push-queue for zero-latency live delta streaming
|
|
684
679
|
const queue = []
|
|
@@ -700,6 +695,12 @@ export async function* streamMoATurn({ targetPreset, userPrompt, messages, callL
|
|
|
700
695
|
callLlm,
|
|
701
696
|
cwd,
|
|
702
697
|
onProgress: pushUpdate,
|
|
698
|
+
onStreamDelta: (delta) => {
|
|
699
|
+
pushUpdate(delta)
|
|
700
|
+
},
|
|
701
|
+
prices,
|
|
702
|
+
historyFilePath,
|
|
703
|
+
liveCanvas,
|
|
703
704
|
})
|
|
704
705
|
.catch((err) => ({ error: err }))
|
|
705
706
|
.finally(() => {
|
|
@@ -736,7 +737,7 @@ export async function* streamMoATurn({ targetPreset, userPrompt, messages, callL
|
|
|
736
737
|
|
|
737
738
|
const elapsedSec = Math.floor((Date.now() - startTime) / 1000)
|
|
738
739
|
if (!done && Date.now() - lastYieldTime >= 3000) {
|
|
739
|
-
yield { type: 'text-delta', index: 0, text: `⏳ *[${elapsedSec}
|
|
740
|
+
yield { type: 'text-delta', index: 0, text: `⏳ *[${elapsedSec}s] Still processing...*\n` }
|
|
740
741
|
lastYieldTime = Date.now()
|
|
741
742
|
}
|
|
742
743
|
}
|
|
@@ -748,7 +749,7 @@ export async function* streamMoATurn({ targetPreset, userPrompt, messages, callL
|
|
|
748
749
|
}
|
|
749
750
|
|
|
750
751
|
if (result?.error) {
|
|
751
|
-
const errText = '\n\n⚠️
|
|
752
|
+
const errText = '\n\n⚠️ **Mixture of Agents error**: ' + (result.error?.message || String(result.error))
|
|
752
753
|
yield { type: 'text-delta', index: 0, text: errText }
|
|
753
754
|
yield { type: 'block-end', index: 0, block: { type: 'text', text: errText } }
|
|
754
755
|
yield { type: 'finish', reason: { kind: 'stop' } }
|
|
@@ -762,12 +763,15 @@ export async function* streamMoATurn({ targetPreset, userPrompt, messages, callL
|
|
|
762
763
|
|
|
763
764
|
yield { type: 'text-delta', index: 0, text: '\n---\n\n' + formatted }
|
|
764
765
|
yield { type: 'block-end', index: 0, block: { type: 'text', text: formatted } }
|
|
765
|
-
yield {
|
|
766
|
+
yield {
|
|
767
|
+
type: 'usage',
|
|
768
|
+
usage: {
|
|
769
|
+
inputTokens: result?.usage?.totalTokens || 0,
|
|
770
|
+
outputTokens: Math.round((formatted.length || 0) / 4),
|
|
771
|
+
},
|
|
772
|
+
}
|
|
766
773
|
yield { type: 'finish', reason: { kind: 'stop' } }
|
|
767
774
|
} finally {
|
|
768
|
-
|
|
769
|
-
await cleanMoaWorkspaces(cwd)
|
|
770
|
-
}
|
|
775
|
+
// cleanup
|
|
771
776
|
}
|
|
772
777
|
}
|
|
773
|
-
|