@goodandready/dsh-moa 0.2.64 → 0.2.65
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/lib/commands.js +83 -0
- package/lib/errors.js +176 -0
- package/lib/index.js +15 -0
- package/lib/moa-candidates.js +33 -11
- package/package.json +1 -1
package/lib/commands.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Official Slash Command Registration for /moa via dsh-commands service (#275)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { service } from './services.js'
|
|
6
|
+
|
|
7
|
+
export function registerMoaCommands(ctx, { live, moaService }) {
|
|
8
|
+
const registerWithService = (cmdCtx) => {
|
|
9
|
+
if (!cmdCtx) return
|
|
10
|
+
const commands = service(cmdCtx, 'commands')
|
|
11
|
+
if (!commands || typeof commands.register !== 'function') return
|
|
12
|
+
|
|
13
|
+
const formatPresetsList = (cfg) => {
|
|
14
|
+
const presets = Array.isArray(cfg?.presets) ? cfg.presets : []
|
|
15
|
+
if (presets.length === 0) return 'No presets configured in dsh-moa.'
|
|
16
|
+
return '### 🧠 Configured MoA Presets:\n' + presets.map((p, i) =>
|
|
17
|
+
`- **${p.name}** (${p.id || `preset-${i + 1}`}): ${p.description || 'Consensus preset'} | ${p.references?.length || p.reference_models?.length || 0} advisors`
|
|
18
|
+
).join('\n')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const commandHandler = async (invocation) => {
|
|
22
|
+
const rawText = typeof invocation === 'string' ? invocation : (invocation?.text || invocation?.raw || invocation?.args || '')
|
|
23
|
+
const trimmed = String(rawText).trim()
|
|
24
|
+
|
|
25
|
+
if (!trimmed || trimmed === 'help' || trimmed === '--help') {
|
|
26
|
+
return {
|
|
27
|
+
text: '🧠 **MoA Command Guide**:\n- `/moa presets` — list configured presets\n- `/moa history` — view recent runs\n- `/moa status` — check plugin status\n- `/moa [--preset <name>] <prompt>` — run MoA consensus in chat',
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (trimmed === 'presets' || trimmed === 'list') {
|
|
32
|
+
const cfg = typeof live === 'function' ? live() : {}
|
|
33
|
+
return { text: formatPresetsList(cfg) }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (trimmed === 'status') {
|
|
37
|
+
const cfg = typeof live === 'function' ? live() : {}
|
|
38
|
+
return {
|
|
39
|
+
text: `🧠 **MoA Plugin Status**: ${cfg?.enabled !== false ? 'Enabled' : 'Disabled'} | Default preset: \`${cfg?.default_preset || 'default'}\``,
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (trimmed === 'history') {
|
|
44
|
+
const hist = moaService?.getHistory ? moaService.getHistory(5, 0) : { runs: [] }
|
|
45
|
+
if (!hist?.runs?.length) return { text: 'No MoA runs recorded in history.' }
|
|
46
|
+
return {
|
|
47
|
+
text: '### 📜 Recent MoA Runs:\n' + hist.runs.slice(0, 5).map((r) =>
|
|
48
|
+
`- \`${r.id?.slice(0, 8)}\` (${r.preset || 'default'}): "${r.prompt?.slice(0, 40)}..." (Winner: ${r.winnerModel || 'none'}, $${(r.totalCostUsd || 0).toFixed(4)})`
|
|
49
|
+
).join('\n'),
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
text: `🧠 MoA turn dispatched: "${trimmed}". The turn will execute with live consensus streaming.`,
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const unregister = commands.register({
|
|
59
|
+
definitionId: '@goodandready/dsh-moa',
|
|
60
|
+
name: 'moa',
|
|
61
|
+
description: 'Run Mixture of Agents consensus or inspect presets (/moa [presets|history|status|<prompt>])',
|
|
62
|
+
input: { hint: '[presets|history|status|<prompt>]' },
|
|
63
|
+
handler: commandHandler,
|
|
64
|
+
execute: async (rawArgs, invocation = null) => {
|
|
65
|
+
const res = await commandHandler(invocation || rawArgs)
|
|
66
|
+
return res?.text || String(res || '')
|
|
67
|
+
},
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
const eff = typeof cmdCtx.effect === 'function' ? cmdCtx.effect : (fn) => fn()
|
|
71
|
+
eff(() => () => {
|
|
72
|
+
try {
|
|
73
|
+
if (typeof unregister === 'function') unregister()
|
|
74
|
+
} catch (_e) {}
|
|
75
|
+
}, 'dsh-moa: slash command registration')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (service(ctx, 'commands')?.register) {
|
|
79
|
+
registerWithService(ctx)
|
|
80
|
+
} else if (typeof ctx.inject === 'function') {
|
|
81
|
+
ctx.inject(['commands'], registerWithService)
|
|
82
|
+
}
|
|
83
|
+
}
|
package/lib/errors.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized LLM and MoA Pipeline Error Taxonomy and Classification
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export class MoaError extends Error {
|
|
6
|
+
/**
|
|
7
|
+
* @param {string} message
|
|
8
|
+
* @param {object} [options]
|
|
9
|
+
*/
|
|
10
|
+
constructor(message, options = {}) {
|
|
11
|
+
super(message)
|
|
12
|
+
this.name = new.target.name || 'MoaError'
|
|
13
|
+
this.code = options.code || 'MOA_ERROR'
|
|
14
|
+
this.status = typeof options.status === 'number' ? options.status : undefined
|
|
15
|
+
this.isRetryable = Boolean(options.isRetryable)
|
|
16
|
+
this.fallbackEligible = options.fallbackEligible !== false
|
|
17
|
+
this.cause = options.cause
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class RateLimitError extends MoaError {
|
|
22
|
+
constructor(message, options = {}) {
|
|
23
|
+
super(message, {
|
|
24
|
+
code: 'RATE_LIMIT_EXCEEDED',
|
|
25
|
+
status: 429,
|
|
26
|
+
isRetryable: true,
|
|
27
|
+
fallbackEligible: true,
|
|
28
|
+
...options,
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class ContextOverflowError extends MoaError {
|
|
34
|
+
constructor(message, options = {}) {
|
|
35
|
+
super(message, {
|
|
36
|
+
code: 'CONTEXT_WINDOW_EXCEEDED',
|
|
37
|
+
status: 400,
|
|
38
|
+
isRetryable: false,
|
|
39
|
+
fallbackEligible: true,
|
|
40
|
+
...options,
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class AuthenticationError extends MoaError {
|
|
46
|
+
constructor(message, options = {}) {
|
|
47
|
+
super(message, {
|
|
48
|
+
code: 'AUTHENTICATION_FAILED',
|
|
49
|
+
status: options.status || 401,
|
|
50
|
+
isRetryable: false,
|
|
51
|
+
fallbackEligible: false,
|
|
52
|
+
...options,
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class ModelNotFoundError extends MoaError {
|
|
58
|
+
constructor(message, options = {}) {
|
|
59
|
+
super(message, {
|
|
60
|
+
code: 'MODEL_NOT_FOUND',
|
|
61
|
+
status: 404,
|
|
62
|
+
isRetryable: false,
|
|
63
|
+
fallbackEligible: true,
|
|
64
|
+
...options,
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export class TransientNetworkError extends MoaError {
|
|
70
|
+
constructor(message, options = {}) {
|
|
71
|
+
super(message, {
|
|
72
|
+
code: 'TRANSIENT_NETWORK_ERROR',
|
|
73
|
+
status: options.status || 503,
|
|
74
|
+
isRetryable: true,
|
|
75
|
+
fallbackEligible: true,
|
|
76
|
+
...options,
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Classifies an unknown error or exception into a structured MoA domain error.
|
|
83
|
+
*
|
|
84
|
+
* @param {unknown} err
|
|
85
|
+
* @returns {MoaError}
|
|
86
|
+
*/
|
|
87
|
+
export function classifyLlmError(err) {
|
|
88
|
+
if (!err) return new MoaError('Unknown error')
|
|
89
|
+
if (err instanceof MoaError) return err
|
|
90
|
+
|
|
91
|
+
const msg = String(err.message || err).toLowerCase()
|
|
92
|
+
const status = Number(err.status || err.statusCode || (msg.match(/\b(401|403|404|429|500|502|503|504)\b/)?.[1])) || undefined
|
|
93
|
+
const code = String(err.code || '').toUpperCase()
|
|
94
|
+
|
|
95
|
+
// Rate limit / Quota
|
|
96
|
+
if (
|
|
97
|
+
status === 429 ||
|
|
98
|
+
msg.includes('rate limit') ||
|
|
99
|
+
msg.includes('too many requests') ||
|
|
100
|
+
msg.includes('tpm') ||
|
|
101
|
+
msg.includes('rpm') ||
|
|
102
|
+
msg.includes('quota exceeded') ||
|
|
103
|
+
code.includes('RATE_LIMIT') ||
|
|
104
|
+
code.includes('QUOTA')
|
|
105
|
+
) {
|
|
106
|
+
return new RateLimitError(err.message || String(err), { status: 429, cause: err })
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Context window overflow
|
|
110
|
+
if (
|
|
111
|
+
msg.includes('context window') ||
|
|
112
|
+
msg.includes('context length') ||
|
|
113
|
+
msg.includes('maximum context') ||
|
|
114
|
+
msg.includes('too long') ||
|
|
115
|
+
msg.includes('token limit') ||
|
|
116
|
+
code.includes('CONTEXT_WINDOW')
|
|
117
|
+
) {
|
|
118
|
+
return new ContextOverflowError(err.message || String(err), { status: 400, cause: err })
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Authentication & permissions
|
|
122
|
+
if (
|
|
123
|
+
status === 401 ||
|
|
124
|
+
status === 403 ||
|
|
125
|
+
msg.includes('unauthorized') ||
|
|
126
|
+
msg.includes('invalid api key') ||
|
|
127
|
+
msg.includes('forbidden') ||
|
|
128
|
+
msg.includes('authentication') ||
|
|
129
|
+
code.includes('AUTH') ||
|
|
130
|
+
code.includes('INVALID_CREDENTIAL')
|
|
131
|
+
) {
|
|
132
|
+
return new AuthenticationError(err.message || String(err), { status: status || 401, cause: err })
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Model not found / unavailable
|
|
136
|
+
if (
|
|
137
|
+
status === 404 ||
|
|
138
|
+
msg.includes('model not found') ||
|
|
139
|
+
msg.includes('does not exist') ||
|
|
140
|
+
msg.includes('unknown model') ||
|
|
141
|
+
code.includes('MODEL_NOT_FOUND')
|
|
142
|
+
) {
|
|
143
|
+
return new ModelNotFoundError(err.message || String(err), { status: 404, cause: err })
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Transient network / gateway / timeout
|
|
147
|
+
if (
|
|
148
|
+
status === 502 ||
|
|
149
|
+
status === 503 ||
|
|
150
|
+
status === 504 ||
|
|
151
|
+
msg.includes('timeout') ||
|
|
152
|
+
msg.includes('econnreset') ||
|
|
153
|
+
msg.includes('etimedout') ||
|
|
154
|
+
msg.includes('network') ||
|
|
155
|
+
msg.includes('socket hang up') ||
|
|
156
|
+
msg.includes('service unavailable') ||
|
|
157
|
+
msg.includes('bad gateway') ||
|
|
158
|
+
code.includes('TIMEOUT') ||
|
|
159
|
+
code.includes('ECONNRESET')
|
|
160
|
+
) {
|
|
161
|
+
return new TransientNetworkError(err.message || String(err), { status: status || 503, cause: err })
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return new MoaError(err.message || String(err), { status, cause: err })
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Returns true if the error qualifies for candidate failover to a reserve model.
|
|
169
|
+
*
|
|
170
|
+
* @param {unknown} err
|
|
171
|
+
* @returns {boolean}
|
|
172
|
+
*/
|
|
173
|
+
export function isFailoverEligible(err) {
|
|
174
|
+
const classified = classifyLlmError(err)
|
|
175
|
+
return classified.fallbackEligible
|
|
176
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { registerMoaCommands } from './commands.js'
|
|
1
2
|
import { service, getWebServerPort } from './services.js'
|
|
2
3
|
import crypto from 'node:crypto'
|
|
3
4
|
import path from 'node:path'
|
|
@@ -185,6 +186,9 @@ export function apply(ctx, config) {
|
|
|
185
186
|
ctx.inject(['webServer'], registerWebSurfaces)
|
|
186
187
|
}
|
|
187
188
|
|
|
189
|
+
// Official slash command /moa registration via dsh-commands (#275)
|
|
190
|
+
registerMoaCommands(ctx, { live, moaService })
|
|
191
|
+
|
|
188
192
|
// Session turn interceptor: route turn to real aggregator model & stream MoA synthesis
|
|
189
193
|
ctx.effect(() => {
|
|
190
194
|
return () => {
|
|
@@ -370,3 +374,14 @@ export {
|
|
|
370
374
|
handleWorkbenchGet,
|
|
371
375
|
handleWorkbenchAssemble,
|
|
372
376
|
} from './moa-workbench.js'
|
|
377
|
+
export { registerMoaCommands } from './commands.js'
|
|
378
|
+
export {
|
|
379
|
+
MoaError,
|
|
380
|
+
RateLimitError,
|
|
381
|
+
ContextOverflowError,
|
|
382
|
+
AuthenticationError,
|
|
383
|
+
ModelNotFoundError,
|
|
384
|
+
TransientNetworkError,
|
|
385
|
+
classifyLlmError,
|
|
386
|
+
isFailoverEligible,
|
|
387
|
+
} from './errors.js'
|
package/lib/moa-candidates.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { classifyLlmError, isFailoverEligible } from './errors.js'
|
|
1
2
|
import { logger } from './logger.js'
|
|
2
3
|
/**
|
|
3
4
|
* DeepSeek Harness Mixture of Agents (MoA) — Candidate Fan-Out & Retry
|
|
@@ -18,14 +19,13 @@ export async function callWithTransientRetry(callLlmFn, callArgs, maxRetries = 0
|
|
|
18
19
|
return await callLlmFn(callArgs)
|
|
19
20
|
} catch (err) {
|
|
20
21
|
attempt++
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
if (attempt <= maxRetries && isTransient && !callArgs?.signal?.aborted) {
|
|
22
|
+
const classified = classifyLlmError(err)
|
|
23
|
+
if (attempt <= maxRetries && classified.isRetryable && !callArgs?.signal?.aborted) {
|
|
24
24
|
await new Promise((r) => setTimeout(r, retryDelayMs))
|
|
25
25
|
if (callArgs?.signal?.aborted) throw new Error('Aborted')
|
|
26
26
|
continue
|
|
27
27
|
}
|
|
28
|
-
throw
|
|
28
|
+
throw classified
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
}
|
|
@@ -176,10 +176,29 @@ export async function runReferencesParallel(references, messages, options = {},
|
|
|
176
176
|
return
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
-
|
|
179
|
+
const classified = classifyLlmError(err)
|
|
180
|
+
const canFailover = isFailoverEligible(classified)
|
|
181
|
+
|
|
182
|
+
// Reserve model failover: checks slot.fallbacks, options.fallback_models, or options.local_fallback_models
|
|
180
183
|
let fallbackRecovered = false
|
|
181
|
-
|
|
182
|
-
|
|
184
|
+
const fallbackCandidates = [
|
|
185
|
+
...(Array.isArray(slot?.fallbacks) ? slot.fallbacks : []),
|
|
186
|
+
...(Array.isArray(options.fallback_models) ? options.fallback_models : []),
|
|
187
|
+
...(options.local_fallback_enabled && Array.isArray(options.local_fallback_models) ? options.local_fallback_models : []),
|
|
188
|
+
].filter(Boolean)
|
|
189
|
+
|
|
190
|
+
const seenFb = new Set([`${slot.provider}:${slot.model}`])
|
|
191
|
+
const eligibleFallbacks = []
|
|
192
|
+
for (const fb of fallbackCandidates) {
|
|
193
|
+
const key = `${fb.provider}:${fb.model}`
|
|
194
|
+
if (!seenFb.has(key)) {
|
|
195
|
+
seenFb.add(key)
|
|
196
|
+
eligibleFallbacks.push(fb)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (canFailover && eligibleFallbacks.length > 0) {
|
|
201
|
+
for (const fbSlot of eligibleFallbacks) {
|
|
183
202
|
if (options.signal?.aborted) break
|
|
184
203
|
const fbLabel = slotLabel(fbSlot)
|
|
185
204
|
const fbAbortCtrl = new AbortController()
|
|
@@ -198,7 +217,7 @@ export async function runReferencesParallel(references, messages, options = {},
|
|
|
198
217
|
|
|
199
218
|
try {
|
|
200
219
|
if (typeof onProgress === 'function') {
|
|
201
|
-
onProgress(`🔄 *Candidate ${i + 1} (${label})
|
|
220
|
+
onProgress(`🔄 *Candidate ${i + 1} (${label}) hit ${classified.name}, failover to ${fbLabel}...*\n`)
|
|
202
221
|
}
|
|
203
222
|
const fbCallPromise = callWithTransientRetry(
|
|
204
223
|
callLlm,
|
|
@@ -243,14 +262,15 @@ export async function runReferencesParallel(references, messages, options = {},
|
|
|
243
262
|
ok: true,
|
|
244
263
|
was_fallback: true,
|
|
245
264
|
original_model: label,
|
|
265
|
+
failoverReason: classified.code,
|
|
246
266
|
}
|
|
247
267
|
fallbackRecovered = true
|
|
248
268
|
if (typeof onProgress === 'function') {
|
|
249
|
-
onProgress(`✅ *Candidate ${i + 1}/${total} recovered using
|
|
269
|
+
onProgress(`✅ *Candidate ${i + 1}/${total} recovered using failover (${fbLabel})*\n`)
|
|
250
270
|
}
|
|
251
271
|
break
|
|
252
272
|
} catch (fbErr) {
|
|
253
|
-
logger.warn(`[dsh-moa]
|
|
273
|
+
logger.warn(`[dsh-moa] Reserve model ${fbLabel} also failed:`, fbErr?.message || fbErr)
|
|
254
274
|
} finally {
|
|
255
275
|
if (fbTimer) clearTimeout(fbTimer)
|
|
256
276
|
if (removeParentAbort) removeParentAbort()
|
|
@@ -260,7 +280,7 @@ export async function runReferencesParallel(references, messages, options = {},
|
|
|
260
280
|
|
|
261
281
|
if (!fallbackRecovered) {
|
|
262
282
|
finishedCount++
|
|
263
|
-
const errMsg = err?.message || String(err)
|
|
283
|
+
const errMsg = classified?.message || err?.message || String(err)
|
|
264
284
|
logger.warn(`[dsh-moa] Reference ${label} failed:`, errMsg)
|
|
265
285
|
if (typeof onProgress === 'function') {
|
|
266
286
|
onProgress(`⚠️ *Candidate ${i + 1}/${total} (${label}) error: ${errMsg}*\n`)
|
|
@@ -276,6 +296,8 @@ export async function runReferencesParallel(references, messages, options = {},
|
|
|
276
296
|
costUsd: 0,
|
|
277
297
|
ok: false,
|
|
278
298
|
error: errMsg,
|
|
299
|
+
errorClass: classified.name,
|
|
300
|
+
errorCode: classified.code,
|
|
279
301
|
}
|
|
280
302
|
}
|
|
281
303
|
} finally {
|