mocode-ai 1.5.2 → 1.5.3

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.
@@ -382,6 +382,8 @@ runtimeOrContext = defaultRuntime) {
382
382
  },
383
383
  onStepStart: () => spinner.start(t('agent.thinking')),
384
384
  onChatDone: () => spinner.stop(),
385
+ // 退避重试要在状态行可见:否则用户面对的是几十秒到几分钟的静止 spinner,与卡死无异。
386
+ onModelRetry: (r) => spinner.start(t('agent.retrying', { seconds: Math.max(1, Math.round(r.waitMs / 1000)), attempt: r.attempt })),
385
387
  // 流式实时用量 → 底栏 context 进度条左侧 chip;轮末由 repl 清空。
386
388
  onLiveUsage: (u) => layout.setLiveUsage(u),
387
389
  onTextEnd: () => {
@@ -1,4 +1,20 @@
1
1
  import { estimatePromptTokens, estimateTokens, isContextLengthError, } from '../llm/index.js';
2
+ /**
3
+ * 沿 cause 链(≤3 层)取第一个 errno,供 trace 取证。
4
+ * undici 把底层 errno 挂在 cause 上(`TypeError: fetch failed` → cause `read ECONNRESET`),
5
+ * openai SDK 再包一层 APIConnectionError 后,顶层 code 恒为 undefined;不留这一项,
6
+ * 事后只能看到一个 'Error',无法区分 DNS 失败 / 连接被拒 / TLS 握手失败。
7
+ */
8
+ function traceCauseCode(err) {
9
+ let cur = err;
10
+ for (let depth = 0; depth <= 3 && cur && typeof cur === 'object'; depth++) {
11
+ const e = cur;
12
+ if (typeof e.code === 'string')
13
+ return e.code;
14
+ cur = e.cause;
15
+ }
16
+ return undefined;
17
+ }
2
18
  /** Executes context preparation plus exactly one model step, including the single overflow retry path. */
3
19
  export async function runModelTurn(input) {
4
20
  const { opts, ctx, history, historyManager, runtimeContextState, scheduler, contextTrimmer, modelRunner, activeTools, runPolicy, step, cacheState, turnLifecycle, cancellationLifecycle, rebuildHistoryIndexes, } = input;
@@ -112,28 +128,44 @@ export async function runModelTurn(input) {
112
128
  onText,
113
129
  onToolCall,
114
130
  onProgress: reportLive,
115
- onRetry: (retry) => emitTrace('model_retry', {
116
- model: requestModel,
117
- provider,
118
- attempt: retry.attempt,
119
- nextAttempt: retry.nextAttempt,
120
- waitMs: retry.waitMs,
121
- code: retry.code,
122
- }),
131
+ onRetry: (retry) => {
132
+ emitTrace('model_retry', {
133
+ model: requestModel,
134
+ provider,
135
+ attempt: retry.attempt,
136
+ nextAttempt: retry.nextAttempt,
137
+ waitMs: retry.waitMs,
138
+ code: retry.code,
139
+ });
140
+ // 退避最长可到 30s、最坏累计数分钟。只写 trace 的话用户看到的就是「卡住不动」,
141
+ // 与「直接报错终止」一样不可解释 —— 所以同时转达给宿主做可见反馈。
142
+ hooks.onModelRetry?.(retry);
143
+ },
123
144
  };
124
145
  const runChatOnce = async () => {
125
146
  try {
126
147
  return await modelRunner.run({ history: requestHistory, handlers: chatHandlers, tools: activeTools }, signal);
127
148
  }
128
149
  catch (error) {
129
- const errorValue = error && typeof error === 'object' ? error : undefined;
150
+ const errorValue = error && typeof error === 'object'
151
+ ? error
152
+ : undefined;
130
153
  emitTrace('model_end', {
131
154
  model: requestModel,
132
155
  provider,
133
156
  status: signal?.aborted ? 'aborted' : 'error',
134
157
  code: typeof errorValue?.status === 'number'
135
158
  ? `HTTP_${errorValue.status}`
136
- : (errorValue?.code ?? errorValue?.name ?? 'MODEL_ERROR'),
159
+ : // 构造器名优先于 `name`:SDK 的 APIError 家族 err.name 恒为 'Error'(无信息量),
160
+ // 构造器名才分得出 APIConnectionError(建连失败)/ APIError(流内报错)。
161
+ (errorValue?.code ??
162
+ error?.constructor?.name ??
163
+ errorValue?.name ??
164
+ 'MODEL_ERROR'),
165
+ // 取证用:错误文案与 cause 链上的 errno。没有这两项时,trace 只留一个 'Error',
166
+ // 事后无法判断到底是 DNS 失败、连接被拒还是 TLS 握手失败(实测踩过)。
167
+ message: typeof errorValue?.message === 'string' ? errorValue.message.slice(0, 300) : undefined,
168
+ causeCode: traceCauseCode(error),
137
169
  durationMs: Date.now() - modelStartedAt,
138
170
  });
139
171
  throw error;
@@ -190,6 +190,7 @@ const zhCN = {
190
190
  'agent.thinking': '思考中',
191
191
  'agent.generating': '生成 {tool}',
192
192
  'agent.executing': '执行 {tool}',
193
+ 'agent.retrying': '连接异常,{seconds}s 后重试(第 {attempt} 次)',
193
194
  'agent.noReply': '(无回复)',
194
195
  'agent.maxSteps': '达到最大步数({count}),本轮停止。',
195
196
  'agent.aborted': '(已中断)',
@@ -519,6 +520,7 @@ const en = {
519
520
  'agent.thinking': 'Thinking',
520
521
  'agent.generating': 'Generating {tool}',
521
522
  'agent.executing': 'Running {tool}',
523
+ 'agent.retrying': 'Connection lost, retrying in {seconds}s (attempt {attempt})',
522
524
  'agent.noReply': '(no reply)',
523
525
  'agent.maxSteps': 'Maximum steps reached ({count}); this turn has stopped.',
524
526
  'agent.aborted': '(aborted)',
package/dist/llm/index.js CHANGED
@@ -107,7 +107,9 @@ export function isRetryableError(err, signal) {
107
107
  if (!err || typeof err !== 'object')
108
108
  return false;
109
109
  const e = err;
110
- if (e.name === 'AbortError' || e.name === 'APIUserAbortError')
110
+ // SDK 的 APIError 家族不设 this.name(见下),判类别一律用构造器名。
111
+ const ctor = e.constructor?.name ?? '';
112
+ if (e.name === 'AbortError' || e.name === 'APIUserAbortError' || ctor === 'APIUserAbortError')
111
113
  return false;
112
114
  // OpenAI SDK APIError 走 status 分支(覆盖 4xx/5xx/429)
113
115
  const status = e.status;
@@ -118,23 +120,26 @@ export function isRetryableError(err, signal) {
118
120
  return true;
119
121
  return false;
120
122
  }
121
- // Node 网络错 code(APIConnectionError 内部也会带一个)
122
- const code = e.code;
123
- if (code === 'ETIMEDOUT' ||
124
- code === 'ECONNRESET' ||
125
- code === 'ENOTFOUND' ||
126
- code === 'EAI_AGAIN' ||
127
- code === 'ECONNREFUSED' ||
128
- code === 'EPIPE') {
123
+ // 证书 / 协议层不匹配是**永久性**错误,重试只会白等满退避(10 次≈两分钟)。
124
+ // 必须先于下面的宽兜底判定:fetch failed / cause 里的证书错文案都会被宽兜底捞走。
125
+ if (causeChainFrames(err).some((f) => (!!f.code && FATAL_TRANSPORT_CODE.test(f.code)) || (!!f.message && FATAL_TRANSPORT_MESSAGE.test(f.message))))
126
+ return false;
127
+ // Node 网络错 errno:顶层没有就沿 cause 链找(undici 把 errno 藏在 cause 里)。
128
+ if (causeChainFrames(err).some((f) => !!f.code && RETRYABLE_ERRNO.has(f.code)))
129
129
  return true;
130
- }
131
- // OpenAI SDK 的网络错类(无 status)
132
- if (e.name === 'APIConnectionError' || e.name === 'APIConnectionTimeoutError')
130
+ // OpenAI SDK 的网络错类(无 status)。必须用**构造器名**:SDK 的 APIError 家族只做
131
+ // `super(message)`,从不设 this.name(`err.name` 恒为 'Error')—— 旧代码这里写
132
+ // `e.name === 'APIConnectionError'` 是永不命中的死分支,于是建连失败(DNS / 连接被拒 /
133
+ // TLS 握手 / 半路断流)一次即抛、整轮 run 直接终止(trace 里 model_end.code 只剩 'Error')。
134
+ if (ctor === 'APIConnectionError' || ctor === 'APIConnectionTimeoutError')
133
135
  return true;
134
- // 兜底:错误信息里出现 timeout 字样(部分代理把错误折叠成普通 Error)
135
- if (typeof e.message === 'string' && /\btime(d|ed)?\s*out\b|ETIMEDOUT/i.test(e.message)) {
136
+ // 兜底:文案。部分代理把错误折叠成普通 Error;SDK 的 APIConnectionError 默认文案就是
137
+ // 'Connection error.'(负载里没有任何细节,`code` 也为 undefined,只能靠文案兜)。
138
+ const msg = typeof e.message === 'string' ? e.message : '';
139
+ if (/\btime(d|ed)?\s*out\b|ETIMEDOUT/i.test(msg))
140
+ return true;
141
+ if (/^connection error\.?$/i.test(msg.trim()) || /\bfetch failed\b/i.test(msg))
136
142
  return true;
137
- }
138
143
  return false;
139
144
  }
140
145
  /**
@@ -161,16 +166,54 @@ const STREAM_BREAK_CODES = new Set([
161
166
  'ERR_SOCKET_CONNECTION_TIMEOUT',
162
167
  ]);
163
168
  const STREAM_BREAK_MESSAGE = /premature close|other side closed|socket hang up|\bterminated\b|stream (?:closed|ended) (?:prematurely|unexpectedly)|econnreset|econnaborted/i;
164
- /** 沿 cause 链(≤3 层)找「连接被掐断」的信号;命中即认为响应流非正常结束。 */
165
- function hasStreamBreakSignal(err, depth = 0) {
166
- if (!err || typeof err !== 'object' || depth > 3)
167
- return false;
168
- const e = err;
169
- if (typeof e.code === 'string' && STREAM_BREAK_CODES.has(e.code))
170
- return true;
171
- if (typeof e.message === 'string' && STREAM_BREAK_MESSAGE.test(e.message))
172
- return true;
173
- return hasStreamBreakSignal(e.cause, depth + 1);
169
+ /**
170
+ * 「连接层」可重试 errno。与 STREAM_BREAK_CODES 的区别:这些在**建连/发请求**阶段就失败
171
+ * (DNS 解析、拒绝连接、路由不可达、连接超时),压根没有响应流可言,但处置一样 —— 重试。
172
+ * 旧实现只在顶层 `err.code` 上查,而 undici 把 errno 埋在 cause 里,永远查不到。
173
+ */
174
+ const RETRYABLE_ERRNO = new Set([
175
+ 'ETIMEDOUT',
176
+ 'ECONNRESET',
177
+ 'ENOTFOUND',
178
+ 'EAI_AGAIN',
179
+ 'ECONNREFUSED',
180
+ 'EPIPE',
181
+ 'ECONNABORTED',
182
+ 'ENETUNREACH',
183
+ 'EHOSTUNREACH',
184
+ 'ERR_SOCKET_CONNECTION_TIMEOUT',
185
+ 'UND_ERR_CONNECT_TIMEOUT',
186
+ 'UND_ERR_SOCKET',
187
+ ]);
188
+ /** 证书 / TLS 协议不匹配:重试必然再错,判死以免白等退避。 */
189
+ const FATAL_TRANSPORT_CODE = /^(?:ERR_SSL|ERR_TLS|ERR_OSSL|UNABLE_TO_VERIFY|DEPTH_ZERO|SELF_SIGNED|CERT_|EPROTO)/;
190
+ const FATAL_TRANSPORT_MESSAGE = /certificate|self[- ]signed|\bEPROTO\b|wrong version number|unsupported protocol/i;
191
+ /**
192
+ * 沿错误自身与 cause 链(≤3 层)收集每层的 code / message。
193
+ *
194
+ * 为什么必须看 cause:undici 把底层 errno 挂在 cause 上(`TypeError: fetch failed` →
195
+ * cause `Error: read ECONNRESET`,code 在 cause 里),openai SDK 又把那次 fetch 失败再包一层
196
+ * `APIConnectionError({ cause })`(`core.mjs` 的 catch 分支)。只看顶层 `err.code` / `err.message`
197
+ * 永远是 undefined / 'Connection error.' —— 这正是这类故障长期无法被识别、无法重试的根因。
198
+ */
199
+ function causeChainFrames(err) {
200
+ const frames = [];
201
+ let cur = err;
202
+ for (let depth = 0; depth <= 3; depth++) {
203
+ if (!cur || typeof cur !== 'object')
204
+ break;
205
+ const e = cur;
206
+ frames.push({
207
+ code: typeof e.code === 'string' ? e.code : undefined,
208
+ message: typeof e.message === 'string' ? e.message : undefined,
209
+ });
210
+ cur = e.cause;
211
+ }
212
+ return frames;
213
+ }
214
+ /** 错误链(含 cause)上是否存在「连接被掐断」的信号。 */
215
+ function hasStreamBreakSignal(err) {
216
+ return causeChainFrames(err).some((f) => (!!f.code && STREAM_BREAK_CODES.has(f.code)) || (!!f.message && STREAM_BREAK_MESSAGE.test(f.message)));
174
217
  }
175
218
  /**
176
219
  * 判定「服务端在响应流中途报错 / 流被中途掐断」——HTTP 层已建连并开始流式返回(状态码 200),
@@ -186,8 +229,9 @@ function hasStreamBreakSignal(err, depth = 0) {
186
229
  * 这三类都不沾 → 被当成「不可重试的客户端请求错」一次即抛,整轮 run 直接终止。但它们的真实语义
187
230
  * 是「服务端/链路在生成到一半时挂了」,属瞬时故障,重试是正确处置(已实测同一会话更大 prompt 可成功)。
188
231
  *
189
- * 只认 APIError 本身,不认子类:APIConnectionError / APIConnectionTimeoutError 已由
190
- * isRetryableError 的 name 分支覆盖,而 APIUserAbortError 是用户中断、绝不能重试。
232
+ * 只认 APIError 本身,不认子类:APIConnectionError / APIConnectionTimeoutError 是**建连阶段**
233
+ * 失败,归 isRetryableError 的构造器名分支(它有 10 次 HTTP 层预算,更合适);而 APIUserAbortError
234
+ * 是用户中断,绝不能重试。
191
235
  * 判据用构造器名而非 instanceof:同进程若存在 openai 的多份模块实例(ESM/CJS 混载),
192
236
  * instanceof 会失配。
193
237
  *
@@ -424,7 +468,9 @@ function retryErrorCode(error) {
424
468
  const value = error;
425
469
  if (typeof value.status === 'number')
426
470
  return `HTTP_${value.status}`;
427
- return value.code ?? value.name ?? 'RETRYABLE_ERROR';
471
+ // 优先构造器名:SDK 的 APIError 家族 `err.name` 恒为 'Error'(无信息量),
472
+ // 构造器名才区分得出是 APIConnectionError(建连失败)还是流内 APIError。
473
+ return value.code ?? value.constructor?.name ?? value.name ?? 'RETRYABLE_ERROR';
428
474
  }
429
475
  /** Bind chat dispatch, retries and built-in providers to one explicit runtime. */
430
476
  export function createChatTransport(runtime) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mocode-ai",
3
- "version": "1.5.2",
3
+ "version": "1.5.3",
4
4
  "description": "终端编码 agent:LLM + tool-call 循环 + 流式输出(含思考)+ 25 个工具,接任意 OpenAI 兼容后端。",
5
5
  "type": "module",
6
6
  "bin": {