w-dispatch-ai 1.0.7 → 1.0.8

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.
Files changed (51) hide show
  1. package/README.md +27 -1
  2. package/dist/w-dispatch-ai.umd.js +2 -2
  3. package/dist/w-dispatch-ai.umd.js.map +1 -1
  4. package/docs/WDispatchAi.mjs.html +4 -3
  5. package/docs/adapters.mjs.html +2 -2
  6. package/docs/castPintOr.mjs.html +109 -0
  7. package/docs/dfTimeoutMs.mjs.html +2 -2
  8. package/docs/dispatchAi.mjs.html +14 -13
  9. package/docs/dispatchAiFallback.mjs.html +156 -97
  10. package/docs/dispatchAiWkf.mjs.html +2 -2
  11. package/docs/dispatchAntigravity.mjs.html +10 -14
  12. package/docs/dispatchApiOpenaiCompat.mjs.html +50 -64
  13. package/docs/dispatchClaude.mjs.html +8 -13
  14. package/docs/dispatchCodex.mjs.html +8 -13
  15. package/docs/dispatchOpencode.mjs.html +8 -13
  16. package/docs/getCliArgs.mjs.html +2 -2
  17. package/docs/getErrorResult.mjs.html +12 -5
  18. package/docs/getErrorType.mjs.html +171 -0
  19. package/docs/global.html +1275 -88
  20. package/docs/index.html +2 -2
  21. package/docs/resolveProviders.mjs.html +2 -2
  22. package/docs/wkf_callAiWithFallback.mjs.html +14 -10
  23. package/docs/wkf_extractJsonLoose.mjs.html +2 -2
  24. package/docs/wkf_runFanout.mjs.html +8 -8
  25. package/docs/wkf_runFanoutPipeline.mjs.html +14 -18
  26. package/docs/wkf_runRolePipeline.mjs.html +5 -4
  27. package/package.json +1 -1
  28. package/src/WDispatchAi.mjs +2 -1
  29. package/src/castPintOr.mjs +37 -0
  30. package/src/dispatchAi.mjs +12 -11
  31. package/src/dispatchAiFallback.mjs +154 -95
  32. package/src/dispatchAntigravity.mjs +8 -12
  33. package/src/dispatchApiOpenaiCompat.mjs +48 -62
  34. package/src/dispatchClaude.mjs +6 -11
  35. package/src/dispatchCodex.mjs +6 -11
  36. package/src/dispatchOpencode.mjs +6 -11
  37. package/src/getErrorResult.mjs +10 -3
  38. package/src/getErrorType.mjs +99 -0
  39. package/src/wkf/callAiWithFallback.mjs +12 -8
  40. package/src/wkf/runFanout.mjs +6 -6
  41. package/src/wkf/runFanoutPipeline.mjs +12 -16
  42. package/src/wkf/runRolePipeline.mjs +3 -2
  43. package/test/tools/fakeServerForApiTest.mjs +4 -1
  44. package/test/unit-callAiWithFallback.test.mjs +24 -0
  45. package/test/unit-castPintOr.test.mjs +28 -0
  46. package/test/unit-dispatchAiFallback.test.mjs +137 -0
  47. package/test/unit-dispatchApiOpenaiCompat.test.mjs +39 -0
  48. package/test/unit-getErrorResult.test.mjs +13 -1
  49. package/test/unit-getErrorType.test.mjs +36 -0
  50. package/test/unit-runFanout.test.mjs +14 -0
  51. package/test/unit-runRolePipeline.test.mjs +12 -0
@@ -1,12 +1,12 @@
1
1
  import get from 'lodash-es/get.js'
2
2
  import omit from 'lodash-es/omit.js'
3
- import ispint from 'wsemi/src/ispint.mjs'
4
- import cint from 'wsemi/src/cint.mjs'
5
3
  import isbol from 'wsemi/src/isbol.mjs'
6
4
  import isestr from 'wsemi/src/isestr.mjs'
7
5
  import execCli from 'wsemi/src/execCli.mjs'
8
6
  import getCliArgs from './getCliArgs.mjs'
9
7
  import getErrorResult from './getErrorResult.mjs'
8
+ import { attachErrorType } from './getErrorType.mjs'
9
+ import castPintOr from './castPintOr.mjs'
10
10
  import dfTimeoutMs from './dfTimeoutMs.mjs'
11
11
 
12
12
 
@@ -108,23 +108,18 @@ async function dispatchClaude(prompt, opt = {}) {
108
108
  )
109
109
 
110
110
  //timeoutMs, 無效回退全套件統一預設(dfTimeoutMs=300000), 各轉接器一致令呼叫方無須記多套數字
111
- let timeoutMs = get(opt, 'timeoutMs', null)
112
- if (!ispint(timeoutMs)) {
113
- timeoutMs = dfTimeoutMs
114
- }
115
- else {
116
- timeoutMs = cint(timeoutMs)
117
- }
111
+ let timeoutMs = castPintOr(get(opt, 'timeoutMs', null), dfTimeoutMs)
118
112
 
119
113
  //optCli, 剔除本轉接器自用鍵後原樣轉傳, 令呼叫端可用execCli全部設定(例如onStdout、maxBuffer)
120
114
  let optCli = omit(opt, OWN_KEYS)
121
115
 
122
- //execCli, prompt一律走stdin
123
- return execCli(exe, args, {
116
+ //execCli, prompt一律走stdin; 失敗結果補上機器可讀之errorType(僅機械可判者, 見getErrorType.mjs)
117
+ let r = await execCli(exe, args, {
124
118
  ...optCli,
125
119
  input: prompt,
126
120
  timeoutMs,
127
121
  })
122
+ return attachErrorType(r)
128
123
  }
129
124
 
130
125
 
@@ -1,11 +1,11 @@
1
1
  import get from 'lodash-es/get.js'
2
2
  import omit from 'lodash-es/omit.js'
3
- import ispint from 'wsemi/src/ispint.mjs'
4
- import cint from 'wsemi/src/cint.mjs'
5
3
  import isestr from 'wsemi/src/isestr.mjs'
6
4
  import execCli from 'wsemi/src/execCli.mjs'
7
5
  import getCliArgs from './getCliArgs.mjs'
8
6
  import getErrorResult from './getErrorResult.mjs'
7
+ import { attachErrorType } from './getErrorType.mjs'
8
+ import castPintOr from './castPintOr.mjs'
9
9
  import dfTimeoutMs from './dfTimeoutMs.mjs'
10
10
 
11
11
 
@@ -109,23 +109,18 @@ async function dispatchCodex(prompt, opt = {}) {
109
109
  )
110
110
 
111
111
  //timeoutMs, 無效回退全套件統一預設(dfTimeoutMs=300000), 各轉接器一致令呼叫方無須記多套數字
112
- let timeoutMs = get(opt, 'timeoutMs', null)
113
- if (!ispint(timeoutMs)) {
114
- timeoutMs = dfTimeoutMs
115
- }
116
- else {
117
- timeoutMs = cint(timeoutMs)
118
- }
112
+ let timeoutMs = castPintOr(get(opt, 'timeoutMs', null), dfTimeoutMs)
119
113
 
120
114
  //optCli, 剔除本轉接器自用鍵後原樣轉傳, 令呼叫端可用execCli全部設定(例如onStdout、maxBuffer)
121
115
  let optCli = omit(opt, OWN_KEYS)
122
116
 
123
- //execCli, prompt一律走stdin
124
- return execCli(exe, args, {
117
+ //execCli, prompt一律走stdin; 失敗結果補上機器可讀之errorType(僅機械可判者, 見getErrorType.mjs)
118
+ let r = await execCli(exe, args, {
125
119
  ...optCli,
126
120
  input: prompt,
127
121
  timeoutMs,
128
122
  })
123
+ return attachErrorType(r)
129
124
  }
130
125
 
131
126
 
@@ -1,12 +1,12 @@
1
1
  import get from 'lodash-es/get.js'
2
2
  import omit from 'lodash-es/omit.js'
3
- import ispint from 'wsemi/src/ispint.mjs'
4
- import cint from 'wsemi/src/cint.mjs'
5
3
  import isobj from 'wsemi/src/isobj.mjs'
6
4
  import isestr from 'wsemi/src/isestr.mjs'
7
5
  import execCli from 'wsemi/src/execCli.mjs'
8
6
  import getCliArgs from './getCliArgs.mjs'
9
7
  import getErrorResult from './getErrorResult.mjs'
8
+ import { attachErrorType } from './getErrorType.mjs'
9
+ import castPintOr from './castPintOr.mjs'
10
10
  import dfTimeoutMs from './dfTimeoutMs.mjs'
11
11
 
12
12
 
@@ -177,24 +177,19 @@ async function dispatchOpencode(prompt, opt = {}) {
177
177
  }
178
178
 
179
179
  //timeoutMs, 無效回退全套件統一預設(dfTimeoutMs=300000), 各轉接器一致令呼叫方無須記多套數字
180
- let timeoutMs = get(opt, 'timeoutMs', null)
181
- if (!ispint(timeoutMs)) {
182
- timeoutMs = dfTimeoutMs
183
- }
184
- else {
185
- timeoutMs = cint(timeoutMs)
186
- }
180
+ let timeoutMs = castPintOr(get(opt, 'timeoutMs', null), dfTimeoutMs)
187
181
 
188
182
  //optCli, 剔除本轉接器自用鍵後原樣轉傳, 令呼叫端可用execCli全部設定(例如onStdout、maxBuffer)
189
183
  let optCli = omit(opt, OWN_KEYS)
190
184
 
191
- //execCli, prompt一律走stdin
192
- return execCli(exe, args, {
185
+ //execCli, prompt一律走stdin; 失敗結果補上機器可讀之errorType(僅機械可判者, 見getErrorType.mjs)
186
+ let r = await execCli(exe, args, {
193
187
  ...optCli,
194
188
  input: prompt,
195
189
  env,
196
190
  timeoutMs,
197
191
  })
192
+ return attachErrorType(r)
198
193
  }
199
194
 
200
195
 
@@ -9,31 +9,38 @@ import isestr from 'wsemi/src/isestr.mjs'
9
9
  * 無須區分「參數錯誤」與「CLI執行失敗」兩種來源
10
10
  *
11
11
  * @param {String} error 輸入錯誤訊息字串
12
- * @returns {Object} 回傳結果物件,內含ok(布林值,恆為false)、stdout(空字串)、stderr(空字串)、code(null)、error(錯誤訊息字串)、durationMs(0)、attempts(0)
12
+ * @param {String} [errorType='params'] 輸入機器可讀之錯誤類別字串(一覽見getErrorType.mjs檔頭),預設'params'(參數/設定檢核失敗)
13
+ * @returns {Object} 回傳結果物件,內含ok(布林值,恆為false)、stdout(空字串)、stderr(空字串)、code(null)、error(錯誤訊息字串)、errorType(錯誤類別字串)、durationMs(0)、attempts(0)
13
14
  * @example
14
15
  *
15
16
  * import getErrorResult from './src/getErrorResult.mjs'
16
17
  *
17
18
  * console.log(getErrorResult('prompt must be a non-empty string'))
18
- * // => { ok: false, stdout: '', stderr: '', code: null, error: 'prompt must be a non-empty string', durationMs: 0, attempts: 0 }
19
+ * // => { ok: false, stdout: '', stderr: '', code: null, error: 'prompt must be a non-empty string', errorType: 'params', durationMs: 0, attempts: 0 }
19
20
  *
20
21
  * console.log(getErrorResult(null).error)
21
22
  * // => 'unknown error'
22
23
  *
23
24
  */
24
- function getErrorResult(error) {
25
+ function getErrorResult(error, errorType = 'params') {
25
26
 
26
27
  //check, 非有效字串時給予預設訊息, 確保error欄位恆為非空字串
27
28
  if (!isestr(error)) {
28
29
  error = 'unknown error'
29
30
  }
30
31
 
32
+ //check errorType, 非有效字串回退'params'
33
+ if (!isestr(errorType)) {
34
+ errorType = 'params'
35
+ }
36
+
31
37
  return {
32
38
  ok: false,
33
39
  stdout: '',
34
40
  stderr: '',
35
41
  code: null,
36
42
  error,
43
+ errorType,
37
44
  durationMs: 0,
38
45
  attempts: 0,
39
46
  }
@@ -0,0 +1,99 @@
1
+ import get from 'lodash-es/get.js'
2
+ import isestr from 'wsemi/src/isestr.mjs'
3
+
4
+
5
+ // getErrorType.mjs — 由失敗結果推導機器可讀之errorType
6
+ //
7
+ // 【為何需要】失敗分類原本只能靠error字串前綴(TIMEOUT/ENOENT/OUTPUT_VALIDATION_FAILED...)
8
+ // 比對, 呼叫端各自重寫字串規則既脆弱又易漂移。errorType提供穩定的機器可讀分類,
9
+ // error字串保留不動(人讀), 兩者並存。
10
+ //
11
+ // 【只判機械可判者, 不猜測】本函數僅涵蓋「由error字樣/結構即可100%確定」的類別,
12
+ // 與dispatchAiFallback之isKeyIndependentFail同一組機械判準; 各家CLI的其餘失敗
13
+ // (額度上限/金鑰無效/服務端錯誤等, 字樣各家不同且隨版本漂移)一律歸'exec',
14
+ // 不維護簽章表(與否決金鑰停用清單同一理由, 呼叫端需細分時用coolDetect式注入自判)。
15
+ //
16
+ // 【errorType一覽(僅失敗結果帶此欄, 成功結果無)】
17
+ // 'params' 參數/設定檢核失敗(進入執行前即被擋, getErrorResult預設)
18
+ // 'timeout' 逾時(execCli強殺或API abort, error以TIMEOUT開頭)
19
+ // 'spawn' 子進程無法啟動(執行檔不存在ENOENT/命令列過長ENAMETOOLONG)
20
+ // 'validation' stdout未過validate(OUTPUT_VALIDATION_FAILED)
21
+ // 'exec' CLI非零離開碼之一般執行失敗(未能再機械細分)
22
+ // 'http' HTTP非2xx(code為狀態碼, 僅api類)
23
+ // 'fetch' 網路層錯誤(DNS/連線拒絕, 僅api類)
24
+ // 'tool-unsupported' 模型回tool_calls而本轉接器不支援工具(僅api類)
25
+ // 'invalid-response' 回應缺choices[0].message.content(僅api類)
26
+ // 'aborted' shouldStop中止(僅dispatchAiFallback)
27
+ // 'budget' 時間預算用盡(僅dispatchAiFallback)
28
+
29
+
30
+ /**
31
+ * 由失敗結果物件推導機器可讀之errorType(僅機械可判者,其餘歸'exec',不猜測)
32
+ *
33
+ * 判準與dispatchAiFallback之isKeyIndependentFail同組:error以TIMEOUT開頭為'timeout'、
34
+ * 含ENOENT或ENAMETOOLONG為'spawn'、恰為OUTPUT_VALIDATION_FAILED為'validation',
35
+ * 其餘失敗一律'exec'(各家CLI字樣不同且隨版本漂移,不維護簽章表)
36
+ *
37
+ * @param {Object} r 輸入失敗結果物件(取其error欄位判別)
38
+ * @returns {String} 回傳errorType字串
39
+ * @example
40
+ *
41
+ * import getErrorType from './src/getErrorType.mjs'
42
+ *
43
+ * console.log(getErrorType({ error: 'TIMEOUT after 300s' }))
44
+ * // => 'timeout'
45
+ *
46
+ * console.log(getErrorType({ error: 'spawn cli ENOENT' }))
47
+ * // => 'spawn'
48
+ *
49
+ * console.log(getErrorType({ error: 'Exit code 1' }))
50
+ * // => 'exec'
51
+ *
52
+ */
53
+ function getErrorType(r) {
54
+ let error = get(r, 'error', '')
55
+ if (!isestr(error)) {
56
+ return 'exec'
57
+ }
58
+ if (error.indexOf('TIMEOUT') === 0) {
59
+ return 'timeout'
60
+ }
61
+ if (error.includes('ENOENT') || error.includes('ENAMETOOLONG')) {
62
+ return 'spawn'
63
+ }
64
+ if (error === 'OUTPUT_VALIDATION_FAILED') {
65
+ return 'validation'
66
+ }
67
+ return 'exec'
68
+ }
69
+
70
+
71
+ /**
72
+ * 失敗結果補上errorType欄位(已帶有效errorType或成功結果則原樣回傳)
73
+ *
74
+ * @param {Object} r 輸入結果物件
75
+ * @returns {Object} 回傳結果物件,失敗且未帶errorType時追加之
76
+ * @example
77
+ *
78
+ * import { attachErrorType } from './src/getErrorType.mjs'
79
+ *
80
+ * console.log(attachErrorType({ ok: false, error: 'TIMEOUT after 10s' }).errorType)
81
+ * // => 'timeout'
82
+ *
83
+ * console.log(attachErrorType({ ok: true, stdout: 'abc' }).errorType)
84
+ * // => undefined
85
+ *
86
+ */
87
+ function attachErrorType(r) {
88
+ if (get(r, 'ok', false) === true) {
89
+ return r
90
+ }
91
+ if (isestr(get(r, 'errorType', null))) {
92
+ return r
93
+ }
94
+ return { ...r, errorType: getErrorType(r) }
95
+ }
96
+
97
+
98
+ export default getErrorType
99
+ export { attachErrorType }
@@ -38,8 +38,9 @@ import extractJsonLoose from './extractJsonLoose.mjs'
38
38
  //本層自用之設定鍵, 其餘鍵(timeoutMs/budgetMs/minAttemptMs/maxRetries/cwd/store/onEvent/
39
39
  //retryDelayMs/maxBuffer等)一律原樣轉傳dispatchAiFallback——與各轉接器「剔除自用鍵後
40
40
  //原樣轉傳」同一約定; 曾因白名單式轉送漏掉minAttemptMs, 令README教學之工作流層
41
- //budget保護靜默失效(2026-08-14使用端實測回報), 故改採omit式轉傳杜絕同類漏鍵
42
- let OWN_KEYS = ['providers', 'spec', 'check', 'parse', 'rawText', 'promptPrefix']
41
+ //budget保護靜默失效(2026-08-14使用端實測回報), 故改採omit式轉傳杜絕同類漏鍵;
42
+ //meta為保留鍵: 呼叫端自有資訊掛此鍵保證永不轉傳(全套件同約定, 見dispatchAiFallback檔頭)
43
+ let OWN_KEYS = ['providers', 'spec', 'check', 'parse', 'rawText', 'promptPrefix', 'meta']
43
44
 
44
45
 
45
46
  //預設防寫檔前綴(禁副作用, 但豁免唯讀查閱——codex以shell讀檔, 一律禁指令等同禁讀檔)
@@ -111,8 +112,9 @@ function buildChain(providers, spec) {
111
112
  * @param {Number} [opt.maxRetries=0] 輸入同家重試次數非負整數,預設0(韌性交給遞補;端點不穩偶發空回之模型可調高令同鍵重試)
112
113
  * @param {String} [opt.cwd=process.cwd()] 輸入子進程工作目錄字串,預設process.cwd()
113
114
  * @param {Object} [opt.store=null] 輸入游標持久化物件{get,set},預設null代表用行程內記憶體
114
- * @param {Function} [opt.onEvent=null] 輸入遞補層事件回調函數,預設null。除上列外之其餘鍵(retryDelayMs、maxBuffer、onStdout等)亦一律原樣轉傳dispatchAiFallback
115
- * @returns {Promise} 回傳Promise,resolve回傳結果物件,內含ok(是否取得可用結果布林值)json(解析後物件,rawText模式下為文字)providerId(實際使用之名稱)keyIndexkeyId、ms(總耗時毫秒)、tried(遞補嘗試歷程陣列)、error(錯誤訊息字串),本函數不會reject
115
+ * @param {*} [opt.meta=undefined] 輸入呼叫端自有資訊(分類、標籤、註記),保留鍵保證永不轉傳下層,預設undefined
116
+ * @param {Function} [opt.onEvent=null] 輸入遞補層事件回調函數,預設null。除上列外之其餘鍵(retryDelayMsmaxBuffershouldStopcoolDetectcooldownMs等)亦一律原樣轉傳dispatchAiFallback
117
+ * @returns {Promise} 回傳Promise,resolve回傳結果物件,內含ok(是否取得可用結果布林值)、json(解析後物件,rawText模式下為文字)、providerId(實際使用之名稱)、keyIndex、keyId、ms(總耗時毫秒)、tried(遞補嘗試歷程陣列)、usage(api類之token用量原樣透傳,CLI類為null)、error(錯誤訊息字串)、errorType(僅失敗時,機器可讀錯誤類別字串,一覽見getErrorType.mjs檔頭),本函數不會reject
116
118
  * @example
117
119
  * //need cli in system PATH
118
120
  *
@@ -145,22 +147,22 @@ async function callAiWithFallback(prompt, opt = {}) {
145
147
  let t0 = Date.now()
146
148
 
147
149
  if (!isestr(prompt)) {
148
- return { ok: false, json: null, error: 'prompt must be a non-empty string', ms: 0, tried: [] }
150
+ return { ok: false, json: null, error: 'prompt must be a non-empty string', errorType: 'params', ms: 0, tried: [] }
149
151
  }
150
152
 
151
153
  let providers = get(opt, 'providers', null)
152
154
  let spec = get(opt, 'spec', null)
153
155
  if (!isobj(providers) || !isobj(spec)) {
154
- return { ok: false, json: null, error: `no valid provider for spec: ${JSON.stringify(spec)}`, ms: 0, tried: [] }
156
+ return { ok: false, json: null, error: `no valid provider for spec: ${JSON.stringify(spec)}`, errorType: 'params', ms: 0, tried: [] }
155
157
  }
156
158
 
157
159
  //buildChain, 名稱查無定義即回報(fail fast), 不讓打錯字的fallback靜默消失
158
160
  let { chain, missing } = buildChain(providers, spec)
159
161
  if (missing.length > 0) {
160
- return { ok: false, json: null, error: `unknown provider name(s): ${missing.join(', ')}`, ms: 0, tried: [] }
162
+ return { ok: false, json: null, error: `unknown provider name(s): ${missing.join(', ')}`, errorType: 'params', ms: 0, tried: [] }
161
163
  }
162
164
  if (chain.length === 0) {
163
- return { ok: false, json: null, error: `no valid provider for spec: ${JSON.stringify(spec)}`, ms: 0, tried: [] }
165
+ return { ok: false, json: null, error: `no valid provider for spec: ${JSON.stringify(spec)}`, errorType: 'params', ms: 0, tried: [] }
164
166
  }
165
167
 
166
168
  let rawText = get(opt, 'rawText', false) === true
@@ -217,7 +219,9 @@ async function callAiWithFallback(prompt, opt = {}) {
217
219
  keyId: (keyIndex === null) ? providerId : `${providerId}#${keyIndex}`,
218
220
  ms: Date.now() - t0,
219
221
  tried: get(r, 'tried', []),
222
+ usage: get(r, 'usage', null), //api類轉接器之token用量原樣透傳, CLI類為null(無可靠來源)
220
223
  error: r.ok ? '' : get(r, 'error', 'unknown error'),
224
+ ...(r.ok ? {} : { errorType: get(r, 'errorType', 'exec') }), //機器可讀錯誤類別, 僅失敗時
221
225
  }
222
226
  }
223
227
 
@@ -50,8 +50,8 @@ ${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, check?, maxRetries?, timeoutMs? }等(check可覆寫頂層檢核;除use/fallback/check外之鍵覆寫該名額呼叫設定)
54
- * @param {Object} opt.integrate 輸入整合名額規格物件{ use, fallback, prompt?, check?, ... },prompt可為(candidates)=>String自訂整合提示詞(省略用預設模板);check為終稿專屬檢核(終稿判準常與候選不同,如須含固定段落),未給則沿用頂層check
53
+ * @param {Array} opt.agents 輸入前段名額規格陣列,各元素{ use, fallback, check?, maxRetries?, timeoutMs? }等(check可覆寫頂層檢核;除use/fallback/check/meta外之鍵覆寫該名額呼叫設定;meta為保留鍵,呼叫端自有資訊掛此鍵保證永不轉傳下層)
54
+ * @param {Object} opt.integrate 輸入整合名額規格物件{ use, fallback, prompt?, check?, ... },prompt可為(candidates)=>String自訂整合提示詞(省略用預設模板);check為終稿專屬檢核(終稿判準常與候選不同,如須含固定段落),未給則沿用頂層check;meta為保留鍵同agents
55
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
@@ -115,9 +115,9 @@ async function runFanout(opt = {}) {
115
115
 
116
116
  //前段: 並行多開, 個別失敗不炸整輪
117
117
  //各名額可自帶check覆寫頂層(候選與終稿判準本可不同); 自spec抽出而非留在overrides,
118
- //否則會被後方明給之check靜默覆蓋(曾為死鍵)
118
+ //否則會被後方明給之check靜默覆蓋(曾為死鍵); meta為保留鍵一併抽出, 保證永不轉傳
119
119
  let rsAgents = await Promise.all(agents.map((spec) => {
120
- let { use, fallback, check: checkAgent, ...overrides } = spec
120
+ let { use, fallback, check: checkAgent, meta, ...overrides } = spec
121
121
  return callAiWithFallback(task, { ...callOpt, ...overrides, providers, spec: { use, fallback }, check: isfun(checkAgent) ? checkAgent : check })
122
122
  }))
123
123
  let candidates = rsAgents.filter((r) => r.ok).map((r) => r.json)
@@ -136,8 +136,8 @@ async function runFanout(opt = {}) {
136
136
  if (!integrate || !isestr(get(integrate, 'use', ''))) {
137
137
  return { ok: false, result: null, integrated: false, agents: rsAgents, candidates, totalMs: Date.now() - t0, error: 'integrate spec (with use) is required' }
138
138
  }
139
- //整合可帶獨立check(終稿判準常較候選嚴, 如須含固定小標題), 未給則沿用頂層check
140
- let { use, fallback, prompt: intPromptFn, check: checkInt, ...intOverrides } = integrate
139
+ //整合可帶獨立check(終稿判準常較候選嚴, 如須含固定小標題), 未給則沿用頂層check; meta為保留鍵一併抽出
140
+ let { use, fallback, prompt: intPromptFn, check: checkInt, meta, ...intOverrides } = integrate
141
141
  let intPrompt = isfun(intPromptFn) ? intPromptFn(candidates) : defaultIntegratePrompt(candidates, opt)
142
142
  let rInt = await callAiWithFallback(intPrompt, { ...callOpt, ...intOverrides, providers, spec: { use, fallback }, check: isfun(checkInt) ? checkInt : check })
143
143
 
@@ -1,8 +1,15 @@
1
- import get from 'lodash-es/get.js'
1
+ import omit from 'lodash-es/omit.js'
2
2
  import runFanout from './runFanout.mjs'
3
3
  import runRolePipeline from './runRolePipeline.mjs'
4
4
 
5
5
 
6
+ //兩段各自剔除「對方專屬鍵」後原樣轉傳(providers與callOpt等共用鍵兩段皆收):
7
+ //曾因白名單式逐鍵轉送漏掉minAttemptMs令下層設定靜默失效(2026-08-14使用端實測回報),
8
+ //故一律採omit式, 任一段日後新增頂層選項時本檔無須跟改
9
+ let B_ONLY_KEYS = ['stages', 'input'] //後段專屬(input由前段成果給定, 不收外部傳入)
10
+ let A_ONLY_KEYS = ['task', 'agents', 'integrate', 'check', 'schema', 'minCandidates'] //前段專屬
11
+
12
+
6
13
  // runFanoutPipeline.mjs — FanoutPipeline工作流(Fanout+RolePipeline): 多開收斂成果接串行角色鏈
7
14
  //
8
15
  // 【結構】先跑runFanout(多開 → 整合), 其成果作為runRolePipeline的input
@@ -69,27 +76,16 @@ import runRolePipeline from './runRolePipeline.mjs'
69
76
  async function runFanoutPipeline(opt = {}) {
70
77
  let t0 = Date.now()
71
78
 
72
- //前段: Fanout(多開 → 整合)
73
- let rA = await runFanout({
74
- providers: get(opt, 'providers', null),
75
- task: get(opt, 'task', ''),
76
- agents: get(opt, 'agents', null),
77
- integrate: get(opt, 'integrate', null),
78
- check: get(opt, 'check', null),
79
- schema: get(opt, 'schema', ''),
80
- minCandidates: get(opt, 'minCandidates', null),
81
- callOpt: get(opt, 'callOpt', {}),
82
- })
79
+ //前段: Fanout(多開 → 整合), 剔除後段專屬鍵後原樣轉傳
80
+ let rA = await runFanout(omit(opt, B_ONLY_KEYS))
83
81
  if (!rA.ok) {
84
82
  return { ok: false, result: null, A: rA, B: null, totalMs: Date.now() - t0, error: `A failed: ${rA.error}` }
85
83
  }
86
84
 
87
- //後段: RolePipeline, input即前段成果
85
+ //後段: RolePipeline, 剔除前段專屬鍵後原樣轉傳, input即前段成果
88
86
  let rB = await runRolePipeline({
89
- providers: get(opt, 'providers', null),
87
+ ...omit(opt, A_ONLY_KEYS),
90
88
  input: rA.result,
91
- stages: get(opt, 'stages', null),
92
- callOpt: get(opt, 'callOpt', {}),
93
89
  })
94
90
 
95
91
  return {
@@ -24,7 +24,8 @@ import callAiWithFallback from './callAiWithFallback.mjs'
24
24
 
25
25
 
26
26
  //各階段規格自用之鍵, 其餘鍵覆寫該階段之呼叫設定
27
- let STAGE_KEYS = ['id', 'use', 'fallback', 'prompt', 'check']
27
+ //meta為保留鍵: 呼叫端於階段規格掛自有資訊(如draft/audit分類)用, 保證永不轉傳下層
28
+ let STAGE_KEYS = ['id', 'use', 'fallback', 'prompt', 'check', 'meta']
28
29
 
29
30
 
30
31
  /**
@@ -39,7 +40,7 @@ let STAGE_KEYS = ['id', 'use', 'fallback', 'prompt', 'check']
39
40
  * @param {Object} [opt={}] 輸入設定物件,預設{}
40
41
  * @param {Object} opt.providers 輸入provider定義表物件(名稱 → 條目)
41
42
  * @param {*} [opt.input=null] 輸入工作流輸入(原始任務字串或前一工作流之成果物件),提供給各階段ctx.input,預設null
42
- * @param {Array} opt.stages 輸入階段規格陣列,各元素{ id, use, fallback, prompt:(ctx)=>String, check?, rawText?, maxRetries?, timeoutMs? }
43
+ * @param {Array} opt.stages 輸入階段規格陣列,各元素{ id, use, fallback, prompt:(ctx)=>String, check?, rawText?, maxRetries?, timeoutMs? }等;meta為保留鍵,呼叫端自有資訊(分類、標籤)掛此鍵保證永不轉傳下層
43
44
  * @param {Object} [opt.callOpt={}] 輸入透傳callAiWithFallback之共用設定,預設{}
44
45
  * @returns {Promise} 回傳Promise,resolve回傳結果物件,內含ok(布林值)、result(最末階段成果)、stages(id對階段完整呼叫結果之物件)、results(id對階段成果之物件)、order(階段id順序陣列)、failedStage(失敗階段id,無失敗為null)、totalMs(總耗時毫秒)、error(錯誤訊息字串),本函數不會reject
45
46
  * @example
@@ -69,7 +69,10 @@ async function fakeServerForApiTest() {
69
69
  let model = body.model || ''
70
70
  let ok = (content) => {
71
71
  res.writeHead(200, { 'Content-Type': 'application/json' })
72
- res.end(JSON.stringify({ choices: [{ message: { role: 'assistant', content } }] }))
72
+ res.end(JSON.stringify({
73
+ choices: [{ message: { role: 'assistant', content } }],
74
+ usage: { prompt_tokens: 3, completion_tokens: 7, total_tokens: 10 }, //供斷言usage原樣透傳
75
+ }))
73
76
  }
74
77
 
75
78
  if (model === 'echo') {
@@ -158,4 +158,28 @@ describe('callAiWithFallback', function() {
158
158
  assert.strict.deepEqual(r, rr)
159
159
  })
160
160
 
161
+ it('shouldStop可經工作流層原樣轉傳, 中止時回報ABORTED', async function() {
162
+ //中止後每個後續呼叫進門即回ABORTED, 整條工作流自然快速收束(檢查點只在遞補層一處)
163
+ let t = await callAiWithFallback('abc', { providers, spec: { use: 'p-claude' }, promptPrefix: '', shouldStop: () => true })
164
+ let r = [t.ok, t.error, t.tried.map((x) => x.outcome)]
165
+ let rr = [false, 'ABORTED', ['aborted']]
166
+ assert.strict.deepEqual(r, rr)
167
+ })
168
+
169
+ it('usage欄位: CLI路徑無可靠來源故為null; meta為保留鍵不影響行為', async function() {
170
+ let t = await callAiWithFallback('abc', { providers, spec: { use: 'p-claude' }, promptPrefix: '', meta: { tag: 'draft' } })
171
+ let r = [t.ok, t.usage, t.json.stdin]
172
+ let rr = [true, null, 'abc']
173
+ assert.strict.deepEqual(r, rr)
174
+ })
175
+
176
+ it('errorType經工作流層透出: 失敗帶類別, 成功無此欄', async function() {
177
+ let t1 = await callAiWithFallback('abc', { providers, spec: { use: 'p-dead' }, promptPrefix: '' })
178
+ let t2 = await callAiWithFallback('abc', { providers, spec: { use: 'p-typo' }, promptPrefix: '' })
179
+ let t3 = await callAiWithFallback('abc', { providers, spec: { use: 'p-claude' }, promptPrefix: '' })
180
+ let r = [[t1.ok, t1.errorType], [t2.ok, t2.errorType], [t3.ok, t3.errorType]]
181
+ let rr = [[false, 'exec'], [false, 'params'], [true, undefined]]
182
+ assert.strict.deepEqual(r, rr)
183
+ })
184
+
161
185
  })
@@ -0,0 +1,28 @@
1
+ import assert from 'assert'
2
+ import castPintOr from '../src/castPintOr.mjs'
3
+
4
+
5
+ describe('castPintOr', function() {
6
+
7
+ it('有效正整數即轉整數回傳', function() {
8
+ let r = [castPintOr(5000, 300000), castPintOr(1, 0), castPintOr('123', 0)]
9
+ let rr = [5000, 1, 123]
10
+ assert.strict.deepEqual(r, rr)
11
+ })
12
+
13
+ it('無效值回退預設值(含null代表不限)', function() {
14
+ let r = []
15
+ for (let v of [null, undefined, 0, -1, 1.5, 'abc', '', {}, [], true]) {
16
+ r.push(castPintOr(v, 300000))
17
+ }
18
+ let rr = [300000, 300000, 300000, 300000, 300000, 300000, 300000, 300000, 300000, 300000]
19
+ assert.strict.deepEqual(r, rr)
20
+ })
21
+
22
+ it('預設值可為null', function() {
23
+ let r = [castPintOr(undefined, null), castPintOr(-5, null), castPintOr(7, null)]
24
+ let rr = [null, null, 7]
25
+ assert.strict.deepEqual(r, rr)
26
+ })
27
+
28
+ })
@@ -539,4 +539,141 @@ describe('dispatchAiFallback', function() {
539
539
  assert.strict.deepEqual(r, rr)
540
540
  })
541
541
 
542
+ it('shouldStop於嘗試邊界檢查, true即停止遞補回報ABORTED', async function() {
543
+ //首次嘗試邊界放行(n=0), 第二次嘗試邊界(st-a已敗轉st-b前)即中止——
544
+ //「斷線後仍空耗整條鏈」縮成「至多再耗當前這一家」
545
+ let n = 0
546
+ let t = await dispatchAiFallback('abc', {
547
+ providers: [
548
+ { id: 'st-a', kind: 'claude', exe: fake.exe, extraArgs: ['--fake-exit=1'] },
549
+ { id: 'st-b', kind: 'claude', exe: fake.exe },
550
+ ],
551
+ shouldStop: () => n++ >= 1,
552
+ })
553
+ let r = [t.ok, t.error, t.tried.map((x) => [x.providerId, x.outcome])]
554
+ let rr = [false, 'ABORTED', [['st-a', 'next-key'], ['st-b', 'aborted']]]
555
+ assert.strict.deepEqual(r, rr)
556
+ })
557
+
558
+ it('shouldStop一開始即true時完全不嘗試; 回調拋出例外視同false不中止', async function() {
559
+ let t1 = await dispatchAiFallback('abc', {
560
+ providers: [{ id: 'st2-a', kind: 'claude', exe: fake.exe }],
561
+ shouldStop: () => true,
562
+ })
563
+ let t2 = await dispatchAiFallback('abc', {
564
+ providers: [{ id: 'st2-a', kind: 'claude', exe: fake.exe }],
565
+ shouldStop: () => {
566
+ throw new Error('callback error should not break the loop')
567
+ },
568
+ })
569
+ let r = [[t1.ok, t1.error, t1.tried.map((x) => x.outcome)], [t2.ok, t2.error]]
570
+ let rr = [[false, 'ABORTED', ['aborted']], [true, '']]
571
+ assert.strict.deepEqual(r, rr)
572
+ })
573
+
574
+ it('coolDetect注入CLI限流簽章判定, 命中即觸發冷卻次輪降尾', async function() {
575
+ let stored = { cursors: {}, cooling: {} }
576
+ let store = {
577
+ get: () => stored,
578
+ set: (s) => {
579
+ stored = s
580
+ }
581
+ }
582
+ //CLI限流埋在stderr(如Zen之FreeUsageLimitError), 內建429/TIMEOUT觸發不到,
583
+ //簽章由呼叫端以coolDetect注入判定
584
+ let pLimited = { id: 'cdx-a', kind: 'claude', exe: fake.exe, extraArgs: ['--fake-exit=1', '--fake-stderr=FreeUsageLimitError: quota exceeded'] }
585
+ let pOk = { id: 'cdx-b', kind: 'claude', exe: fake.exe }
586
+ let coolDetect = (r) => /FreeUsageLimitError/i.test(r.stderr || '')
587
+ let r1 = await dispatchAiFallback('abc', { providers: [pLimited, pOk], store, cooldownMs: 300000, coolDetect })
588
+ let r2 = await dispatchAiFallback('abc', { providers: [pLimited, pOk], store, cooldownMs: 300000, coolDetect })
589
+ let r = [
590
+ r1.providerId,
591
+ typeof stored.cooling['cdx-a'],
592
+ r2.providerId,
593
+ r2.tried.map((x) => x.providerId), //次輪cdx-a降尾, 完全未被嘗試
594
+ ]
595
+ let rr = ['cdx-b', 'number', 'cdx-b', ['cdx-b']]
596
+ assert.strict.deepEqual(r, rr)
597
+ })
598
+
599
+ it('coolDetect拋出例外視同false不觸發冷卻; meta為保留鍵不影響呼叫行為', async function() {
600
+ let stored = { cursors: {}, cooling: {} }
601
+ let store = {
602
+ get: () => stored,
603
+ set: (s) => {
604
+ stored = s
605
+ }
606
+ }
607
+ let t1 = await dispatchAiFallback('abc', {
608
+ providers: [
609
+ { id: 'cde-a', kind: 'claude', exe: fake.exe, extraArgs: ['--fake-exit=1'] },
610
+ { id: 'cde-b', kind: 'claude', exe: fake.exe },
611
+ ],
612
+ store,
613
+ cooldownMs: 300000,
614
+ coolDetect: () => {
615
+ throw new Error('callback error should not break the loop')
616
+ },
617
+ })
618
+ //條目與opt掛meta(保留鍵)不轉傳: 假CLI收到之args與stdin與未掛時完全一致
619
+ let t2 = await dispatchAiFallback('abc', {
620
+ providers: [{ id: 'mt-a', kind: 'claude', exe: fake.exe, model: 'sonnet', meta: { tag: 'draft' } }],
621
+ meta: { batch: 1 },
622
+ })
623
+ let o = JSON.parse(t2.stdout)
624
+ let r = [t1.ok, stored.cooling['cde-a'], t2.ok, o.args, o.stdin]
625
+ let rr = [true, undefined, true, ['-p', '--dangerously-skip-permissions', '--model', 'sonnet'], 'abc']
626
+ assert.strict.deepEqual(r, rr)
627
+ })
628
+
629
+ it('cooled事件於冷卻觸發時發出, tried與失敗事件帶機器可讀errorType', async function() {
630
+ let evs = []
631
+ let stored = { cursors: {}, cooling: {} }
632
+ let store = {
633
+ get: () => stored,
634
+ set: (s) => {
635
+ stored = s
636
+ }
637
+ }
638
+ let t = await dispatchAiFallback('abc', {
639
+ providers: [
640
+ { id: 'ev-hang', kind: 'claude', exe: fake.exe, extraArgs: ['--fake-sleep=9000'], timeoutMs: 500 }, //逾時 → 冷卻觸發
641
+ { id: 'ev-ok', kind: 'claude', exe: fake.exe },
642
+ ],
643
+ store,
644
+ cooldownMs: 300000,
645
+ onEvent: (ev) => evs.push(ev),
646
+ })
647
+ let evCooled = evs.find((x) => x.type === 'cooled')
648
+ let evSkip = evs.find((x) => x.type === 'skip-group')
649
+ let r = [
650
+ t.ok,
651
+ evCooled.providerId,
652
+ evCooled.cooldownMs,
653
+ evCooled.error.indexOf('TIMEOUT') === 0,
654
+ evSkip.errorType, //失敗事件帶機器可讀分類
655
+ t.tried[0].errorType, //tried歷程各項一併帶上
656
+ ]
657
+ let rr = [true, 'ev-hang', 300000, true, 'timeout', 'timeout']
658
+ assert.strict.deepEqual(r, rr)
659
+ })
660
+
661
+ it('最終失敗結果帶errorType: 一般執行失敗exec, 中止aborted, 預算用盡budget', async function() {
662
+ let t1 = await dispatchAiFallback('abc', {
663
+ providers: [{ id: 'et-a', kind: 'claude', exe: fake.exe, extraArgs: ['--fake-exit=1'] }],
664
+ })
665
+ let t2 = await dispatchAiFallback('abc', {
666
+ providers: [{ id: 'et-b', kind: 'claude', exe: fake.exe }],
667
+ shouldStop: () => true,
668
+ })
669
+ let t3 = await dispatchAiFallback('abc', {
670
+ providers: [{ id: 'et-c', kind: 'claude', exe: fake.exe }],
671
+ budgetMs: 60000,
672
+ minAttemptMs: 600000,
673
+ })
674
+ let r = [[t1.ok, t1.errorType], [t2.ok, t2.errorType], [t3.ok, t3.errorType]]
675
+ let rr = [[false, 'exec'], [false, 'aborted'], [false, 'budget']]
676
+ assert.strict.deepEqual(r, rr)
677
+ })
678
+
542
679
  })