@goodandready/dsh-moa 0.2.35 → 0.2.37
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 +1 -1
- package/README.ru.md +1 -1
- package/README.zh.md +3 -0
- package/lib/moa-candidates.js +54 -6
- package/lib/moa-llm.js +74 -6
- package/lib/moa-runner.js +24 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -170,7 +170,7 @@ Seamlessly retains synthesized baseline code from prior turns while pruning inte
|
|
|
170
170
|
`report_generation_enabled` generates comprehensive Markdown & JSON reports detailing candidate metrics, agreement scores, test gate results, and cost breakdowns via `GET /dsh-moa/runs/:id/report`.
|
|
171
171
|
|
|
172
172
|
### 18. Graceful Degradation & Local Fallback Resilience
|
|
173
|
-
When `local_fallback_enabled: true`, candidates that fail due to network outages or rate limits (429/500) automatically recover using configured local models (Ollama / MiniPC).
|
|
173
|
+
When `local_fallback_enabled: true`, candidates that fail due to network outages, timeouts, or rate limits (429/500) automatically recover using configured local models (Ollama / MiniPC). Each fallback model operates with an independent execution budget and an isolated controller linked to the parent turn signal, preventing cloud timeouts from starving fallback models. Additionally, terminal errors and aborted streams are strictly classified, ensuring reliable error propagation and failover.
|
|
174
174
|
|
|
175
175
|
### 6. Live Canvas 1-Click Preview (optional)
|
|
176
176
|
If `@goodandready/dsh-live-canvas` is installed in the same profile, `dsh-moa` pushes the promoted HTML file to the Live Canvas REST contract (`POST /dsh-live-canvas/api/preview`, served by the same harness webServer) and appends a one-click preview link (`/dsh-live-canvas/sandbox/<id>`) to the answer. Without the plugin the step is skipped silently — no errors in the log, no dead links.
|
package/README.ru.md
CHANGED
|
@@ -170,7 +170,7 @@ graph TD
|
|
|
170
170
|
При включении `report_generation_enabled` генерируется подробный аналитический отчет с матрицей моделей, временами задержек, оценками судей, расходами и результатами тестов. Отчет доступен по API `GET /dsh-moa/runs/:id/report?format=md`.
|
|
171
171
|
|
|
172
172
|
### 18. Плавная деградация и локальный фоллбэк
|
|
173
|
-
Опция `local_fallback_enabled` позволяет задать резервные локальные модели (Ollama, vLLM, MiniPC), к которым плагин бесшовно обращается при сбоях сетевых API или превышении лимитов rate limit (429/500).
|
|
173
|
+
Опция `local_fallback_enabled` позволяет задать резервные локальные модели (Ollama, vLLM, MiniPC), к которым плагин бесшовно обращается при сбоях сетевых API, таймаутах или превышении лимитов rate limit (429/500). Каждая фоллбэк-модель запускается с независимым бюджетом времени и изолированным контроллером отмены, связанным с сигналом основного хода, предотвращая исчерпание времени ожидания из-за задержек облачных провайдеров. Стриминговые сбои и терминальные ошибки классифицируются типизированными исключениями с гарантированной защитой от позднего промоушна.
|
|
174
174
|
|
|
175
175
|
### 6. Live Canvas 1-клик предпросмотр (опционально)
|
|
176
176
|
Если в профиле установлен `@goodandready/dsh-live-canvas`, `dsh-moa` отправляет промоученный HTML-файл в REST-контракт Live Canvas (`POST /dsh-live-canvas/api/preview`, тот же webServer харнесса) и добавляет к ответу ссылку на предпросмотр (`/dsh-live-canvas/sandbox/<id>`). Без плагина шаг пропускается тихо — без ошибок в журнале и без битых ссылок.
|
package/README.zh.md
CHANGED
|
@@ -142,6 +142,9 @@ graph TD
|
|
|
142
142
|
### 8. 提升前 Git 快照检查点 (`dsh-time-machine`)
|
|
143
143
|
在将胜出候选者的文件提升并覆盖至项目根目录前,`dsh-moa` 自动调用本地 `dsh-time-machine` 建立影子 Git 检查点 (`moa-pre-promotion: candidate-N`)。如未安装则静默回退,不阻断提升流程。
|
|
144
144
|
|
|
145
|
+
### 18. 平滑降级与本地容灾恢复 (Local Fallback Resilience)
|
|
146
|
+
当 `local_fallback_enabled: true` 时,因网络中断、超时或速率限制(429/500)而失败的候选模型会自动使用配置的本地模型(Ollama / MiniPC)恢复。每个后备模型都拥有独立的超时执行配额与关联主 turn signal 的隔离取消控制器,防止云端超时耗尽本地恢复时间。同时严格检测终止错误与流中断,确保安全容灾且杜绝已取消 turn 的延迟文件提升。
|
|
147
|
+
|
|
145
148
|
### 6. Live Canvas 一键预览(可选)
|
|
146
149
|
若同一 profile 中安装了 `@goodandready/dsh-live-canvas`,`dsh-moa` 会将提升后的 HTML 文件推送到 Live Canvas 的 REST 契约(`POST /dsh-live-canvas/api/preview`,由同一 harness webServer 提供服务),并在回答中附上一键预览链接(`/dsh-live-canvas/sandbox/<id>`)。未安装该插件时此步骤静默跳过——日志无报错,也不会出现死链接。
|
|
147
150
|
|
package/lib/moa-candidates.js
CHANGED
|
@@ -99,6 +99,7 @@ export async function runReferencesParallel(references, messages, options = {},
|
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
const runOne = async () => {
|
|
102
|
+
let candidateTimer = null
|
|
102
103
|
try {
|
|
103
104
|
const callPromise = callWithTransientRetry(
|
|
104
105
|
callLlm,
|
|
@@ -115,14 +116,12 @@ export async function runReferencesParallel(references, messages, options = {},
|
|
|
115
116
|
)
|
|
116
117
|
|
|
117
118
|
const timeoutPromise = new Promise((_, reject) => {
|
|
118
|
-
|
|
119
|
+
candidateTimer = setTimeout(() => {
|
|
119
120
|
bestEffort('candidate timeout abort', () => {
|
|
120
121
|
abortControllers[i].abort(new Error(`Timeout after ${Math.round(timeoutMs / 1000)}s`))
|
|
121
122
|
})
|
|
122
123
|
reject(new Error(`Timeout after ${Math.round(timeoutMs / 1000)}s`))
|
|
123
124
|
}, timeoutMs)
|
|
124
|
-
timer.unref?.()
|
|
125
|
-
abortControllers[i].signal.addEventListener('abort', () => clearTimeout(timer), { once: true })
|
|
126
125
|
})
|
|
127
126
|
|
|
128
127
|
const res = await Promise.race([callPromise, timeoutPromise])
|
|
@@ -153,16 +152,51 @@ export async function runReferencesParallel(references, messages, options = {},
|
|
|
153
152
|
onProgress(`✅ *Candidate ${i + 1}/${total} (${label}, T=${effectiveTemperature}) finished${costStr}* | Live total: ~\$${currentTotalCost.toFixed(4)}\n`)
|
|
154
153
|
}
|
|
155
154
|
} catch (err) {
|
|
155
|
+
if (candidateTimer) clearTimeout(candidateTimer)
|
|
156
|
+
|
|
157
|
+
// If turn was aborted by caller, do NOT run fallbacks
|
|
158
|
+
if (options.signal?.aborted) {
|
|
159
|
+
finishedCount++
|
|
160
|
+
results[i] = {
|
|
161
|
+
index: i + 1,
|
|
162
|
+
slot,
|
|
163
|
+
label,
|
|
164
|
+
role_persona: role,
|
|
165
|
+
temperature: effectiveTemperature,
|
|
166
|
+
text: '[aborted]',
|
|
167
|
+
usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0 },
|
|
168
|
+
costUsd: 0,
|
|
169
|
+
ok: false,
|
|
170
|
+
error: 'Turn aborted',
|
|
171
|
+
}
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
|
|
156
175
|
// Feature 10: Local fallback models if online candidate fails
|
|
157
176
|
let fallbackRecovered = false
|
|
158
177
|
if (options.local_fallback_enabled && Array.isArray(options.local_fallback_models) && options.local_fallback_models.length > 0) {
|
|
159
178
|
for (const fbSlot of options.local_fallback_models) {
|
|
179
|
+
if (options.signal?.aborted) break
|
|
160
180
|
const fbLabel = slotLabel(fbSlot)
|
|
181
|
+
const fbAbortCtrl = new AbortController()
|
|
182
|
+
let fbTimer = null
|
|
183
|
+
let removeParentAbort = null
|
|
184
|
+
|
|
185
|
+
if (options.signal) {
|
|
186
|
+
const onParentAbort = () => fbAbortCtrl.abort(options.signal.reason || new Error('Turn aborted'))
|
|
187
|
+
options.signal.addEventListener('abort', onParentAbort, { once: true })
|
|
188
|
+
removeParentAbort = () => options.signal.removeEventListener('abort', onParentAbort)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const fbTimeoutMs = typeof options.fallbackTimeoutMs === 'number' && options.fallbackTimeoutMs > 0
|
|
192
|
+
? options.fallbackTimeoutMs
|
|
193
|
+
: Math.max(15000, timeoutMs)
|
|
194
|
+
|
|
161
195
|
try {
|
|
162
196
|
if (typeof onProgress === 'function') {
|
|
163
197
|
onProgress(`🔄 *Candidate ${i + 1} (${label}) failed, attempting local fallback to ${fbLabel}...*\n`)
|
|
164
198
|
}
|
|
165
|
-
const
|
|
199
|
+
const fbCallPromise = callWithTransientRetry(
|
|
166
200
|
callLlm,
|
|
167
201
|
{
|
|
168
202
|
provider: fbSlot.provider,
|
|
@@ -170,11 +204,22 @@ export async function runReferencesParallel(references, messages, options = {},
|
|
|
170
204
|
messages: slotMessages,
|
|
171
205
|
temperature: effectiveTemperature,
|
|
172
206
|
maxTokens: options.maxTokens ?? 4096,
|
|
173
|
-
timeoutMs,
|
|
174
|
-
signal:
|
|
207
|
+
timeoutMs: fbTimeoutMs,
|
|
208
|
+
signal: fbAbortCtrl.signal,
|
|
175
209
|
},
|
|
176
210
|
0
|
|
177
211
|
)
|
|
212
|
+
|
|
213
|
+
const fbTimeoutPromise = new Promise((_, reject) => {
|
|
214
|
+
fbTimer = setTimeout(() => {
|
|
215
|
+
bestEffort('fallback timeout abort', () => {
|
|
216
|
+
fbAbortCtrl.abort(new Error(`Timeout after ${Math.round(fbTimeoutMs / 1000)}s`))
|
|
217
|
+
})
|
|
218
|
+
reject(new Error(`Timeout after ${Math.round(fbTimeoutMs / 1000)}s`))
|
|
219
|
+
}, fbTimeoutMs)
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
const fbRes = await Promise.race([fbCallPromise, fbTimeoutPromise])
|
|
178
223
|
const fbText = typeof fbRes === 'string' ? fbRes : (fbRes?.content || fbRes?.text || '')
|
|
179
224
|
const fbUsage = (typeof fbRes === 'object' && fbRes?.usage) ? fbRes.usage : {
|
|
180
225
|
inputTokens: Math.max(1, Math.round(slotMessages.map((m) => m.content).join('').length / 4)),
|
|
@@ -202,6 +247,9 @@ export async function runReferencesParallel(references, messages, options = {},
|
|
|
202
247
|
break
|
|
203
248
|
} catch (fbErr) {
|
|
204
249
|
logger.warn(`[dsh-moa] Fallback ${fbLabel} also failed:`, fbErr?.message || fbErr)
|
|
250
|
+
} finally {
|
|
251
|
+
if (fbTimer) clearTimeout(fbTimer)
|
|
252
|
+
if (removeParentAbort) removeParentAbort()
|
|
205
253
|
}
|
|
206
254
|
}
|
|
207
255
|
}
|
package/lib/moa-llm.js
CHANGED
|
@@ -1,17 +1,49 @@
|
|
|
1
|
+
function combineSignals(signal, timeoutSignal) {
|
|
2
|
+
if (!signal) return timeoutSignal
|
|
3
|
+
if (typeof AbortSignal.any === 'function') {
|
|
4
|
+
try {
|
|
5
|
+
return AbortSignal.any([signal, timeoutSignal])
|
|
6
|
+
} catch {
|
|
7
|
+
// fallback if signal is not an instance of AbortSignal
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
const combinedCtrl = new AbortController()
|
|
11
|
+
const onAbort = () => {
|
|
12
|
+
const reason = signal.aborted ? (signal.reason || new Error('Turn aborted')) : timeoutSignal.reason
|
|
13
|
+
combinedCtrl.abort(reason)
|
|
14
|
+
}
|
|
15
|
+
if (signal.aborted || timeoutSignal.aborted) {
|
|
16
|
+
onAbort()
|
|
17
|
+
return combinedCtrl.signal
|
|
18
|
+
}
|
|
19
|
+
signal.addEventListener?.('abort', onAbort, { once: true })
|
|
20
|
+
timeoutSignal.addEventListener?.('abort', onAbort, { once: true })
|
|
21
|
+
return combinedCtrl.signal
|
|
22
|
+
}
|
|
23
|
+
|
|
1
24
|
/**
|
|
2
25
|
* Unified LLM Dispatcher using ctx.llm.prepareCall / stream
|
|
3
26
|
*/
|
|
4
27
|
|
|
5
28
|
export function createLlmCaller(ctx) {
|
|
6
|
-
return async ({ provider, model, messages, temperature = 0.6, maxTokens = 4096, timeoutMs, onStreamDelta }) => {
|
|
29
|
+
return async ({ provider, model, messages, temperature = 0.6, maxTokens = 4096, timeoutMs, onStreamDelta, signal, stop, reasoningEffort }) => {
|
|
7
30
|
if (!ctx.llm) {
|
|
8
31
|
throw new Error('ctx.llm is not available in cordis context')
|
|
9
32
|
}
|
|
10
33
|
|
|
34
|
+
const callConfig = {
|
|
35
|
+
provider,
|
|
36
|
+
model,
|
|
37
|
+
...(temperature !== undefined ? { temperature } : {}),
|
|
38
|
+
...(maxTokens !== undefined ? { maxTokens } : {}),
|
|
39
|
+
...(stop !== undefined ? { stop } : {}),
|
|
40
|
+
...(reasoningEffort !== undefined ? { reasoningEffort } : {}),
|
|
41
|
+
}
|
|
42
|
+
|
|
11
43
|
let prep
|
|
12
44
|
try {
|
|
13
|
-
// DSH 0.
|
|
14
|
-
prep = await ctx.llm.prepareCall(
|
|
45
|
+
// DSH 0.2.0-rc.2 API expects LlmCallConfig object, supports signal
|
|
46
|
+
prep = await ctx.llm.prepareCall(callConfig, signal)
|
|
15
47
|
} catch (err) {
|
|
16
48
|
if (typeof ctx.llm.prepareCall === 'function') {
|
|
17
49
|
try {
|
|
@@ -27,23 +59,33 @@ export function createLlmCaller(ctx) {
|
|
|
27
59
|
throw new Error(`LLM provider/model ${provider}:${model} could not be prepared`)
|
|
28
60
|
}
|
|
29
61
|
|
|
62
|
+
if (signal?.aborted) {
|
|
63
|
+
throw (signal.reason || new Error('Turn aborted'))
|
|
64
|
+
}
|
|
65
|
+
|
|
30
66
|
const effectiveTimeoutMs = typeof timeoutMs === 'number' && timeoutMs > 0 ? timeoutMs : 120000
|
|
31
67
|
const abortCtrl = new AbortController()
|
|
32
68
|
const timeoutId = setTimeout(() => abortCtrl.abort(new Error(`LLM call timeout after ${Math.round(effectiveTimeoutMs / 1000)}s`)), effectiveTimeoutMs)
|
|
33
69
|
timeoutId.unref?.()
|
|
70
|
+
const combinedSignal = combineSignals(signal, abortCtrl.signal)
|
|
34
71
|
|
|
35
72
|
try {
|
|
73
|
+
const streamConfig = prep.config || callConfig
|
|
36
74
|
const stream = prep.stream({
|
|
75
|
+
...streamConfig,
|
|
37
76
|
messages,
|
|
38
|
-
temperature,
|
|
39
|
-
maxTokens,
|
|
40
|
-
signal:
|
|
77
|
+
temperature: streamConfig.temperature ?? temperature,
|
|
78
|
+
maxTokens: streamConfig.maxTokens ?? maxTokens,
|
|
79
|
+
signal: combinedSignal,
|
|
41
80
|
})
|
|
42
81
|
|
|
43
82
|
let fullText = ''
|
|
44
83
|
let usageInfo = null
|
|
45
84
|
|
|
46
85
|
for await (const chunk of stream) {
|
|
86
|
+
if (combinedSignal?.aborted) {
|
|
87
|
+
throw (combinedSignal.reason || new Error('Turn aborted'))
|
|
88
|
+
}
|
|
47
89
|
if (chunk.type === 'text-delta' && typeof chunk.text === 'string') {
|
|
48
90
|
fullText += chunk.text
|
|
49
91
|
if (typeof onStreamDelta === 'function') {
|
|
@@ -53,9 +95,35 @@ export function createLlmCaller(ctx) {
|
|
|
53
95
|
if (!fullText) fullText = chunk.block.text
|
|
54
96
|
} else if (chunk.type === 'usage' && chunk.usage) {
|
|
55
97
|
usageInfo = chunk.usage
|
|
98
|
+
} else if (chunk.type === 'finish') {
|
|
99
|
+
const reason = chunk.reason
|
|
100
|
+
if (reason && (reason.kind === 'error' || reason.kind === 'aborted')) {
|
|
101
|
+
const failure = reason.failure || {}
|
|
102
|
+
const errMsg = failure.message || failure.error?.message || reason.message || reason.error?.message || `LLM stream finished with ${reason.kind}`
|
|
103
|
+
const err = new Error(errMsg)
|
|
104
|
+
err.code = failure.code || reason.code || (reason.kind === 'error' ? 'LLM_ERROR' : 'ABORT_ERR')
|
|
105
|
+
if (failure.cause) err.cause = failure.cause
|
|
106
|
+
throw err
|
|
107
|
+
}
|
|
108
|
+
} else if (chunk.type === 'error') {
|
|
109
|
+
const errMsg = chunk.error?.message || chunk.message || 'LLM stream error'
|
|
110
|
+
const err = new Error(errMsg)
|
|
111
|
+
err.code = chunk.code || chunk.error?.code || 'LLM_ERROR'
|
|
112
|
+
if (chunk.error?.cause) err.cause = chunk.error.cause
|
|
113
|
+
throw err
|
|
56
114
|
}
|
|
57
115
|
}
|
|
58
116
|
|
|
117
|
+
if (combinedSignal?.aborted) {
|
|
118
|
+
throw (combinedSignal.reason || new Error('Turn aborted'))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (!fullText.trim()) {
|
|
122
|
+
const err = new Error('LLM call returned empty response')
|
|
123
|
+
err.code = 'EMPTY_RESPONSE'
|
|
124
|
+
throw err
|
|
125
|
+
}
|
|
126
|
+
|
|
59
127
|
clearTimeout(timeoutId)
|
|
60
128
|
return {
|
|
61
129
|
content: fullText,
|
package/lib/moa-runner.js
CHANGED
|
@@ -207,6 +207,11 @@ export async function runMoAPipeline({
|
|
|
207
207
|
}
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
+
if (signal?.aborted) {
|
|
211
|
+
await cleanMoaWorkspaces(cwd)
|
|
212
|
+
return { error: new Error('Turn aborted') }
|
|
213
|
+
}
|
|
214
|
+
|
|
210
215
|
// 5b. Test Execution Gate
|
|
211
216
|
await executeTestGateForCandidates({
|
|
212
217
|
cwd,
|
|
@@ -292,6 +297,10 @@ export async function runMoAPipeline({
|
|
|
292
297
|
|
|
293
298
|
// 7. Fast Mode (1 candidate, bypass judge)
|
|
294
299
|
if (isFastMode) {
|
|
300
|
+
if (signal?.aborted) {
|
|
301
|
+
await cleanMoaWorkspaces(cwd)
|
|
302
|
+
return { error: new Error('Turn aborted') }
|
|
303
|
+
}
|
|
295
304
|
const single = successfulRefs[0]
|
|
296
305
|
let promotedFiles = []
|
|
297
306
|
if (single.files?.length > 0) {
|
|
@@ -348,6 +357,11 @@ export async function runMoAPipeline({
|
|
|
348
357
|
}
|
|
349
358
|
}
|
|
350
359
|
|
|
360
|
+
if (signal?.aborted) {
|
|
361
|
+
await cleanMoaWorkspaces(cwd)
|
|
362
|
+
return { error: new Error('Turn aborted') }
|
|
363
|
+
}
|
|
364
|
+
|
|
351
365
|
// 7b. Consilium Round 2 (Peer Critique) if enabled
|
|
352
366
|
if (isPeerCritiqueEnabled && successfulRefs.length > 1) {
|
|
353
367
|
if (typeof onProgress === 'function') {
|
|
@@ -404,6 +418,11 @@ export async function runMoAPipeline({
|
|
|
404
418
|
|
|
405
419
|
// 7c. Feature 2: Multi-Judge Panel & Consensus Voting
|
|
406
420
|
let multiJudgeResult = null
|
|
421
|
+
if (signal?.aborted) {
|
|
422
|
+
await cleanMoaWorkspaces(cwd)
|
|
423
|
+
return { error: new Error('Turn aborted') }
|
|
424
|
+
}
|
|
425
|
+
|
|
407
426
|
if (preset?.multi_judge_enabled && successfulRefs.length > 1) {
|
|
408
427
|
multiJudgeResult = await executeMultiJudgePanel({
|
|
409
428
|
judges: (preset.judge_models && preset.judge_models.length > 0) ? preset.judge_models : [primaryJudge],
|
|
@@ -488,6 +507,11 @@ export async function runMoAPipeline({
|
|
|
488
507
|
successfulRefs.map((r, i) => `### Candidate ${i + 1} (${r.label})\n${r.text}`).join('\n\n')
|
|
489
508
|
}
|
|
490
509
|
|
|
510
|
+
if (signal?.aborted) {
|
|
511
|
+
await cleanMoaWorkspaces(cwd)
|
|
512
|
+
return { error: new Error('Turn aborted') }
|
|
513
|
+
}
|
|
514
|
+
|
|
491
515
|
// 9. Evaluate winner & promote files
|
|
492
516
|
const winningIndex = (multiJudgeResult?.consensus && multiJudgeResult?.winningCandidateIndex)
|
|
493
517
|
? multiJudgeResult.winningCandidateIndex
|