@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.
@@ -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'
@@ -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 msg = err?.message || String(err)
22
- const isTransient = /429|rate limit|502|503|504|econnreset|etimedout|socket hang up/i.test(msg)
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 err
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
- // Feature 10: Local fallback models if online candidate fails
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
- if (options.local_fallback_enabled && Array.isArray(options.local_fallback_models) && options.local_fallback_models.length > 0) {
182
- for (const fbSlot of options.local_fallback_models) {
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}) failed, attempting local fallback to ${fbLabel}...*\n`)
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 fallback (${fbLabel})*\n`)
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] Fallback ${fbLabel} also failed:`, fbErr?.message || fbErr)
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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-moa",
3
- "version": "0.2.64",
3
+ "version": "0.2.65",
4
4
  "description": "Mixture of Agents (MoA) plugin for DeepSeek Harness with /moa slash command",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",