w-dispatch-ai 1.0.5 → 1.0.7

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.
@@ -50,9 +50,9 @@ ${candidates.map((c, i) => `【候選 ${i + 1}】\n${JSON.stringify(c)}`).join('
50
50
  * @param {Object} [opt={}] 輸入設定物件,預設{}
51
51
  * @param {Object} opt.providers 輸入provider定義表物件(名稱 → 條目),透傳callAiWithFallback
52
52
  * @param {String} opt.task 輸入前段各名額共用之任務提示詞字串
53
- * @param {Array} opt.agents 輸入前段名額規格陣列,各元素{ use, fallback, maxRetries?, timeoutMs? }等(use/fallback外之鍵覆寫該名額呼叫設定)
54
- * @param {Object} opt.integrate 輸入整合名額規格物件{ use, fallback, prompt?, ... },prompt可為(candidates)=>String自訂整合提示詞,省略用預設模板
55
- * @param {Function} [opt.check=null] 輸入候選與終稿共用之檢核函數(json)=>Boolean,預設null
53
+ * @param {Array} opt.agents 輸入前段名額規格陣列,各元素{ use, fallback, check?, maxRetries?, timeoutMs? }等(check可覆寫頂層檢核;除use/fallback/check外之鍵覆寫該名額呼叫設定)
54
+ * @param {Object} opt.integrate 輸入整合名額規格物件{ use, fallback, prompt?, check?, ... },prompt可為(candidates)=>String自訂整合提示詞(省略用預設模板);check為終稿專屬檢核(終稿判準常與候選不同,如須含固定段落),未給則沿用頂層check
55
+ * @param {Function} [opt.check=null] 輸入檢核函數(json)=>Boolean,作為候選與終稿之共用預設,名額規格與integrate可各自帶check覆寫,預設null
56
56
  * @param {String} [opt.schema=''] 輸入輸出格式示意字串,供預設整合模板嵌入,預設''
57
57
  * @param {Number} [opt.minCandidates=2] 輸入進入整合所需之最少成功候選數正整數,未達門檻以首位候選為成果,預設2
58
58
  * @param {Object} [opt.callOpt={}] 輸入透傳callAiWithFallback之共用設定(cwd、store、onEvent、timeoutMs、promptPrefix等),預設{}
@@ -114,9 +114,11 @@ async function runFanout(opt = {}) {
114
114
  }
115
115
 
116
116
  //前段: 並行多開, 個別失敗不炸整輪
117
+ //各名額可自帶check覆寫頂層(候選與終稿判準本可不同); 自spec抽出而非留在overrides,
118
+ //否則會被後方明給之check靜默覆蓋(曾為死鍵)
117
119
  let rsAgents = await Promise.all(agents.map((spec) => {
118
- let { use, fallback, ...overrides } = spec
119
- return callAiWithFallback(task, { ...callOpt, ...overrides, providers, spec: { use, fallback }, check })
120
+ let { use, fallback, check: checkAgent, ...overrides } = spec
121
+ return callAiWithFallback(task, { ...callOpt, ...overrides, providers, spec: { use, fallback }, check: isfun(checkAgent) ? checkAgent : check })
120
122
  }))
121
123
  let candidates = rsAgents.filter((r) => r.ok).map((r) => r.json)
122
124
 
@@ -134,9 +136,10 @@ async function runFanout(opt = {}) {
134
136
  if (!integrate || !isestr(get(integrate, 'use', ''))) {
135
137
  return { ok: false, result: null, integrated: false, agents: rsAgents, candidates, totalMs: Date.now() - t0, error: 'integrate spec (with use) is required' }
136
138
  }
137
- let { use, fallback, prompt: intPromptFn, ...intOverrides } = integrate
139
+ //整合可帶獨立check(終稿判準常較候選嚴, 如須含固定小標題), 未給則沿用頂層check
140
+ let { use, fallback, prompt: intPromptFn, check: checkInt, ...intOverrides } = integrate
138
141
  let intPrompt = isfun(intPromptFn) ? intPromptFn(candidates) : defaultIntegratePrompt(candidates, opt)
139
- let rInt = await callAiWithFallback(intPrompt, { ...callOpt, ...intOverrides, providers, spec: { use, fallback }, check })
142
+ let rInt = await callAiWithFallback(intPrompt, { ...callOpt, ...intOverrides, providers, spec: { use, fallback }, check: isfun(checkInt) ? checkInt : check })
140
143
 
141
144
  return {
142
145
  ok: rInt.ok,
@@ -29,7 +29,7 @@ import runRolePipeline from './runRolePipeline.mjs'
29
29
  * @param {Array} opt.agents 輸入前段名額規格陣列(同runFanout)
30
30
  * @param {Object} opt.integrate 輸入前段整合名額規格物件(同runFanout)
31
31
  * @param {Array} opt.stages 輸入後段階段規格陣列(同runRolePipeline),各階段以ctx.input取得前段成果
32
- * @param {Function} [opt.check=null] 輸入前段共用檢核函數,後段各階段自帶check,預設null
32
+ * @param {Function} [opt.check=null] 輸入前段共用檢核函數(前段名額規格與integrate可各自帶check覆寫,同runFanout),後段各階段自帶check,預設null
33
33
  * @param {String} [opt.schema=''] 輸入輸出格式示意字串(供前段預設整合模板),預設''
34
34
  * @param {Number} [opt.minCandidates=2] 輸入前段整合門檻正整數,預設2
35
35
  * @param {Object} [opt.callOpt={}] 輸入透傳兩段之共用呼叫設定,預設{}
@@ -135,6 +135,21 @@ describe('callAiWithFallback', function() {
135
135
  assert.strict.deepEqual(r, rr)
136
136
  })
137
137
 
138
+ it('minAttemptMs等fallback層設定可經工作流層原樣轉傳(曾因白名單漏鍵而失效)', async function() {
139
+ //budgetMs=60000(高於預設門檻20000)+minAttemptMs=600000(遠大於budget):
140
+ //minAttemptMs有被轉送才會立即budget exhausted; 漏鍵時會照樣執行並成功
141
+ let t = await callAiWithFallback('abc', {
142
+ providers,
143
+ spec: { use: 'p-claude' },
144
+ promptPrefix: '',
145
+ budgetMs: 60000,
146
+ minAttemptMs: 600000,
147
+ })
148
+ let r = [t.ok, t.error, t.tried.map((x) => x.outcome)]
149
+ let rr = [false, 'budget exhausted', ['budget-out']]
150
+ assert.strict.deepEqual(r, rr)
151
+ })
152
+
138
153
  it('多金鑰條目經由keys輪替且keyId帶索引', async function() {
139
154
  let t = await callAiWithFallback('abc', { providers, spec: { use: 'p-oc' }, promptPrefix: '' })
140
155
  let auth = JSON.parse(t.json.env.OPENCODE_AUTH_CONTENT)
@@ -316,6 +316,101 @@ describe('dispatchAiFallback', function() {
316
316
  assert.strict.deepEqual(r, rr)
317
317
  })
318
318
 
319
+ it('cooldownMs啟用時, 逾時觸發冷卻且次輪該條目降至鏈尾(只降序不移除)', async function() {
320
+ let stored = { cursors: {}, cooling: {} }
321
+ let store = {
322
+ get: () => stored,
323
+ set: (s) => {
324
+ stored = s
325
+ }
326
+ }
327
+ let pHang = { id: 'cd-hang', kind: 'claude', exe: fake.exe, extraArgs: ['--fake-sleep=9000'], timeoutMs: 500 }
328
+ let pOk = { id: 'cd-ok', kind: 'claude', exe: fake.exe }
329
+ //第1輪: hang在鏈首 → 逾時跳組並觸發冷卻 → ok遞補
330
+ let r1 = await dispatchAiFallback('abc', { providers: [pHang, pOk], store, cooldownMs: 300000 })
331
+ //第2輪: hang冷卻中 → 降至鏈尾, ok先上且成功, hang完全未被嘗試
332
+ let r2 = await dispatchAiFallback('abc', { providers: [pHang, pOk], store, cooldownMs: 300000 })
333
+ let r = [
334
+ r1.providerId,
335
+ r1.tried.map((x) => x.providerId),
336
+ typeof stored.cooling['cd-hang'],
337
+ r2.providerId,
338
+ r2.tried.map((x) => x.providerId), //只有cd-ok, 未浪費一次逾時
339
+ ]
340
+ let rr = ['cd-ok', ['cd-hang', 'cd-ok'], 'number', 'cd-ok', ['cd-ok']]
341
+ assert.strict.deepEqual(r, rr)
342
+ })
343
+
344
+ it('冷卻中的條目於前面全敗時仍會被嘗試, 且成功即解除冷卻', async function() {
345
+ let stored = { cursors: {}, cooling: { 'cd2-a': Date.now() } } //預置: a冷卻中
346
+ let store = {
347
+ get: () => stored,
348
+ set: (s) => {
349
+ stored = s
350
+ }
351
+ }
352
+ let r1 = await dispatchAiFallback('abc', {
353
+ providers: [
354
+ { id: 'cd2-a', kind: 'claude', exe: fake.exe }, //冷卻中 → 降至鏈尾
355
+ { id: 'cd2-b', kind: 'claude', exe: fake.exe, extraArgs: ['--fake-exit=1'] }, //鏈首但必敗
356
+ ],
357
+ store,
358
+ cooldownMs: 300000,
359
+ })
360
+ let r = [
361
+ r1.ok,
362
+ r1.providerId, //b先上失敗 → 降序後的a仍被嘗試且成功
363
+ r1.tried.map((x) => x.providerId),
364
+ stored.cooling['cd2-a'], //成功即解除
365
+ ]
366
+ let rr = [true, 'cd2-a', ['cd2-b', 'cd2-a'], undefined]
367
+ assert.strict.deepEqual(r, rr)
368
+ })
369
+
370
+ it('冷卻過期後恢復原順序並清除過期紀錄', async function() {
371
+ let stored = { cursors: {}, cooling: { 'cd3-a': Date.now() - 1000 } } //1秒前觸發
372
+ let store = {
373
+ get: () => stored,
374
+ set: (s) => {
375
+ stored = s
376
+ }
377
+ }
378
+ let r1 = await dispatchAiFallback('abc', {
379
+ providers: [
380
+ { id: 'cd3-a', kind: 'claude', exe: fake.exe },
381
+ { id: 'cd3-b', kind: 'claude', exe: fake.exe },
382
+ ],
383
+ store,
384
+ cooldownMs: 500, //視窗0.5秒, 已過期
385
+ })
386
+ let r = [r1.providerId, stored.cooling['cd3-a']]
387
+ let rr = ['cd3-a', undefined] //恢復鏈首, 過期紀錄被清除
388
+ assert.strict.deepEqual(r, rr)
389
+ })
390
+
391
+ it('cooldownMs未啟用(預設)時不重排也不寫入cooling', async function() {
392
+ let stored = { cursors: {}, cooling: { 'cd4-a': Date.now() } }
393
+ let store = {
394
+ get: () => stored,
395
+ set: (s) => {
396
+ stored = s
397
+ }
398
+ }
399
+ let r1 = await dispatchAiFallback('abc', {
400
+ providers: [
401
+ { id: 'cd4-a', kind: 'claude', exe: fake.exe, extraArgs: ['--fake-sleep=9000'], timeoutMs: 500 },
402
+ { id: 'cd4-b', kind: 'claude', exe: fake.exe },
403
+ ],
404
+ store,
405
+ })
406
+ let r = [
407
+ r1.tried.map((x) => x.providerId), //cooling有預置紀錄仍不重排(未啟用)
408
+ typeof stored.cooling['cd4-a'] === 'number' && stored.cooling['cd4-b'] === undefined, //逾時亦不新增紀錄
409
+ ]
410
+ let rr = [['cd4-a', 'cd4-b'], true]
411
+ assert.strict.deepEqual(r, rr)
412
+ })
413
+
319
414
  it('失敗事件與tried帶被拒回覆(stdout)與錯誤輸出(stderr)供診斷', async function() {
320
415
  let evs = []
321
416
  let t = await dispatchAiFallback('abc', {
@@ -86,6 +86,14 @@ describe('dispatchAiWkf', function() {
86
86
  assert.strict.deepEqual(r, rr)
87
87
  })
88
88
 
89
+ it('defaults之minAttemptMs經callAi生效(README第一層教學之回歸測試)', async function() {
90
+ let wkf = dispatchAiWkf({ providers, defaults: { promptPrefix: '', budgetMs: 60000, minAttemptMs: 600000 } })
91
+ let t = await wkf.callAi('abc', { spec: { use: 'p-a' } })
92
+ let r = [t.ok, t.error]
93
+ let rr = [false, 'budget exhausted']
94
+ assert.strict.deepEqual(r, rr)
95
+ })
96
+
89
97
  it('runFanout綁定providers與defaults(defaults併入callOpt)', async function() {
90
98
  let wkf = dispatchAiWkf({ providers, defaults: { promptPrefix: '' } })
91
99
  let t = await wkf.runFanout({
@@ -245,6 +245,34 @@ describe('dispatchApiOpenaiCompat', function() {
245
245
  assert.strict.deepEqual(r, rr)
246
246
  })
247
247
 
248
+ it('HTTP 429觸發冷卻: 次輪該條目降至鏈尾', async function() {
249
+ let stored = { cursors: {}, cooling: {} }
250
+ let store = {
251
+ get: () => stored,
252
+ set: (s) => {
253
+ stored = s
254
+ }
255
+ }
256
+ //flaky-429對同一金鑰首次回429; maxRetries預設0故第1輪即失敗並觸發冷卻
257
+ let p429 = { id: 'cd-api-429', kind: 'api-openai-compat', baseURL: svr.url, model: 'flaky-429', keys: ['sk-good-cd429'] }
258
+ let pEcho = { id: 'cd-api-echo', kind: 'api-openai-compat', baseURL: svr.url, model: 'echo', keys: ['sk-good-cd-e'] }
259
+ let r1 = await dispatchAiFallback('abc', { providers: [p429, pEcho], store, cooldownMs: 300000 })
260
+ let r2 = await dispatchAiFallback('abc', { providers: [p429, pEcho], store, cooldownMs: 300000 })
261
+ let r = [
262
+ r1.providerId,
263
+ r1.tried.map((x) => [x.providerId, x.error || null]),
264
+ typeof stored.cooling['cd-api-429'],
265
+ r2.tried.map((x) => x.providerId), //429者已降尾, echo先上即成功
266
+ ]
267
+ let rr = [
268
+ 'cd-api-echo',
269
+ [['cd-api-429', 'HTTP 429'], ['cd-api-echo', null]],
270
+ 'number',
271
+ ['cd-api-echo'],
272
+ ]
273
+ assert.strict.deepEqual(r, rr)
274
+ })
275
+
248
276
  it('可作為工作流provider, 回覆JSON經解析與檢核', async function() {
249
277
  let wkf = dispatchAiWkf({
250
278
  providers: {
@@ -156,6 +156,52 @@ describe('runFanout', function() {
156
156
  assert.strict.deepEqual(r, rr)
157
157
  })
158
158
 
159
+ it('integrate可帶獨立check(終稿判準), 未給則沿用頂層check', async function() {
160
+ //頂層check放行所有echo物件; integrate.check要求stdin含候選標記(僅整合者符合)
161
+ let t1 = await runFanout({
162
+ providers,
163
+ task: 'abc',
164
+ agents: [{ use: 'p-a' }, { use: 'p-b' }],
165
+ integrate: { use: 'p-int', check: (j) => j.stdin.includes('【候選 1】') },
166
+ check: (j) => Array.isArray(j.args),
167
+ callOpt,
168
+ })
169
+ //integrate.check不通過時整合失敗, 但候選不受影響(判準不再互相牽制)
170
+ let t2 = await runFanout({
171
+ providers,
172
+ task: 'abc',
173
+ agents: [{ use: 'p-a' }, { use: 'p-b' }],
174
+ integrate: { use: 'p-int', check: () => false },
175
+ check: (j) => Array.isArray(j.args),
176
+ callOpt,
177
+ })
178
+ let r = [
179
+ [t1.ok, t1.integrated],
180
+ [t2.ok, t2.integrated, t2.candidates.length, t2.error.indexOf('integrate failed:') === 0],
181
+ ]
182
+ let rr = [
183
+ [true, true],
184
+ [false, false, 2, true],
185
+ ]
186
+ assert.strict.deepEqual(r, rr)
187
+ })
188
+
189
+ it('名額規格可帶獨立check覆寫頂層(曾為被靜默覆蓋之死鍵)', async function() {
190
+ let t = await runFanout({
191
+ providers,
192
+ task: 'abc',
193
+ agents: [
194
+ { use: 'p-a', check: () => false }, //此名額專屬判準: 必不過
195
+ { use: 'p-b' }, //沿用頂層(未給即預設放行)
196
+ ],
197
+ integrate: { use: 'p-int' },
198
+ callOpt,
199
+ })
200
+ let r = [t.ok, t.integrated, t.agents[0].ok, t.agents[1].ok, t.candidates.length]
201
+ let rr = [true, false, false, true, 1] //單稿放行(integrated:false)
202
+ assert.strict.deepEqual(r, rr)
203
+ })
204
+
159
205
  it('defaultIntegratePrompt嵌入候選數與schema', function() {
160
206
  let s = defaultIntegratePrompt([{ a: 1 }, { b: 2 }], { schema: '{"x":"..."}' })
161
207
  let r = [s.includes('2 個獨立執行'), s.includes('{"x":"..."}'), s.includes('【候選 2】')]