From 7e672a3dc26301ec8684121375acafeacce71f0c Mon Sep 17 00:00:00 2001 From: Hylouis233 Date: Tue, 20 Oct 2026 22:04:42 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(minimax-bot):=20=E5=9B=9E=E5=90=88?= =?UTF-8?q?=E9=87=8D=E8=AF=95=E4=BB=8E=202=20=E6=AC=A1=E5=BB=B6=E9=95=BF?= =?UTF-8?q?=E5=88=B0=2010=20=E6=A1=A3=EF=BC=88=E4=BB=8D=E6=9C=89=E4=B8=8A?= =?UTF-8?q?=E9=99=90=EF=BC=89=EF=BC=8C400/404/422=20=E4=B8=8D=E9=87=8D?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 要解决的问题:一次 30 秒的服务端抖动就让 Bot 死掉 上游 0.7.4 的重试窗口是 `TURN_RETRY_DELAYS_MS = [8000, 20000]` —— **只重试 2 次**(等 8 秒、等 20 秒),然后就放弃。 而 Host 的 `toLLMRetryDecision()` 对**通用 5xx 故意 fail-closed**: `LLM_UPSTREAM_ERROR`(50113)是「400 及以上的兜底桶」, 把 HTTP 400/404 这种重试无意义的错误和 HTTP 500 这种**暂时性**错误混在一起, 于是分类器只能整体判成不可重试。 两条合起来的后果: 一次持续 30 秒的服务端抖动(`Internal server error (500)` / `502 Bad Gateway` / `net::ERR_NETWORK_CHANGED` / 限流 / 模型过载)就会让回合**第一次失败就死掉**, 只能靠人手点一下才回来。Bot 团队的可用性因此被一次瞬时故障决定。 ## 复现场景 1. 让 MiniMax Bot 团队里任何一个 bot 收到一条任务; 2. 在它请求模型时让上游返回一次 500(或断网、限流、网关 502); 3. 观察:该回合**只尝试了 3 次**(首次 + 2 次重试,共约 28 秒)就返回 error, bot 停在原地,需要人工再点一次。 ## 改动 ### 1. 重试窗口从 2 档延长到 10 档(**仍然有上限**) ``` 8s → 20s → 40s → 60s × 7 ``` 总窗口约 **8 分钟**,足以自愈绝大多数瞬时抖动。 - **有上限**:第 10 次之后把原始错误原样暴露给用户,不会悄悄重试到天荒地老。 - **窗口数组仍是唯一的旋钮**:注入 `retryDelaysMs: []` 依然表示「完全不重试」, 既有语义与既有测试一字未改(`engine-reliability.test.mjs` 51/51 通过)。 ### 2. 400 / 404 / 422 **不重试** 重试它们只是把同一次拒绝重复十遍。Host 自己的注释说得很清楚: *would turn an HTTP 400 or 404 into ten identical attempts*。 ### 3. 错误文本匹配收窄 只认**明确的服务端 / 网络故障形态**: ``` net::ERR_NETWORK_CHANGED / ERR_NETWORK / ECONNRESET|ECONNREFUSED|ETIMEDOUT|EPIPE socket hang up / internal server error / bad gateway|gateway timeout|service unavailable 请求处理超时 / unknown error, 520 / overloaded|rate limit|too many requests|model is busy HTTP 500 / status 502 / code=503(要带上下文,不认裸数字) ``` - **不认裸数字** ⇒「用了 400 tokens」「第 404 步」这类正常消息不会被误判成错误 - **不含 400/404/422** ⇒ 上面那条不重试的规则 - 判定「这次失败值不值得重试」的**第一道**依据始终是 Host 自己给的 `result.error.retryable` 标志;文本匹配只是补上 Host 判不出来的那一类 ### 4. 界面不变 因为**有上限**,原来的 `live.retrying` 文案(带 `n/max`)继续适用, 不需要新增文案,客户端零改动。 ## 测试 - 新增 `tests/turn-retry-backoff.test.mjs`(4 项):窗口形状、5xx/网络故障的识别、 400/404/422 不重试、正常消息不误判 - minimax-bot 全量 **121 项 / 114 通过 / 7 失败** —— 与上游 main **完全相同** (上游自己也有这 7 个失败,与本改动无关) - `npm run validate` 对本插件 **OK**、`repository: OK`;`npm test` 69/69 ## 取舍 - **没有**做「暂时性错误无限重试」:那会让失败静默持续下去,也不符合 「连续重试要设上限」的要求。现在的做法是**把窗口拉长到能覆盖一次真实抖动**, 同时**保留上限**。 - **没有**做「400/404 也重试」:与 Host 的设计意图相反。 - 判定仍然**优先**用 Host 的 `retryable` 标志与 `reason`;文本匹配只是补位, 不是另起一套分类。 --- .../minimax-bot/.minimax-plugin/plugin.json | 2 +- plugins/minimax-ai/minimax-bot/README.md | 31 +++++ .../minimax-bot/miniapp/node/engine.mjs | 119 +++++++++++++++++- .../tests/turn-retry-backoff.test.mjs | 70 +++++++++++ 4 files changed, 217 insertions(+), 5 deletions(-) create mode 100644 plugins/minimax-ai/minimax-bot/tests/turn-retry-backoff.test.mjs diff --git a/plugins/minimax-ai/minimax-bot/.minimax-plugin/plugin.json b/plugins/minimax-ai/minimax-bot/.minimax-plugin/plugin.json index 4ae17ba3..ae872578 100644 --- a/plugins/minimax-ai/minimax-bot/.minimax-plugin/plugin.json +++ b/plugins/minimax-ai/minimax-bot/.minimax-plugin/plugin.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "name": "minimax-bot", "displayName": "MiniMax Bot", - "version": "0.7.4", + "version": "0.7.5", "description": "Turn MiniMax Code into an always-on team of Bots. A Chief Bot helps you build the team; in group chats the admin answers and @mentions hand work to other members. Every Bot has its own octopus avatar and memory, with scheduled tasks and usage stats.", "author": "MiniMax Code", "icon": "icon.png", diff --git a/plugins/minimax-ai/minimax-bot/README.md b/plugins/minimax-ai/minimax-bot/README.md index 1b7a9564..6139f598 100644 --- a/plugins/minimax-ai/minimax-bot/README.md +++ b/plugins/minimax-ai/minimax-bot/README.md @@ -28,6 +28,37 @@ Copy this directory, including the hidden `.minimax-plugin/` directory, into the - **Language**: English and Chinese. By default it follows the operating system's preferred language (the first English or Chinese entry in `navigator.languages`; other languages fall back to English), or you can fix it under Settings → General → Language. The interface, errors, system notices, Chief's default setup, the context Bots receive and the MCP tool descriptions all switch; replies the Bots wrote and text you entered stay as they are. - **Reactions**: react to finished Bot replies in private and group chats. The quick bar offers 👍 ❤️ 🙌 😂 😭 😮, and more emoji can be browsed by category or searched in English or Chinese. Each message holds one reaction: picking a new one replaces it, and picking the same one again removes it. Reactions are saved locally, survive refreshes and restarts, and sync to other open windows. They do not trigger a Bot reply and are not sent to the model. +## 0.7.5 Transient failures get a longer, still-capped retry window + +A Bot that died from a **temporary** failure now comes back on its own. + +The Host classifies a generic 5xx as `LLM_UPSTREAM_ERROR` (50113) and deliberately +fails closed on it: that code is a catch-all bucket for *any* status of 400 or above +except 402/401/403/429/529, so retrying it blindly could turn an HTTP 400 or 404 +into ten identical attempts. The result is that a plain HTTP 500 — which usually +clears on its own within seconds — killed the turn on the first try. + +This plugin sees the error **message**, so it can tell the two apart. Failures that +name a transient condition (a network error, a 5xx, a gateway timeout, rate limiting, +an overloaded model) are retried; failures that are a decision rather than a glitch +are not. + +What changed in 0.7.5: + +- **The retry window is longer.** 0.7.4 retried **twice** (after 8 s and 20 s), so a + blip that lasted 30 seconds killed the Bot. The window is now + `8s → 20s → 40s → 60s × 7` — ten attempts over roughly **eight minutes**, which + covers the overwhelming majority of transient outages. +- **There is still a cap.** After the last attempt the original error surfaces as + it is. The window is also still the single knob: injecting + `retryDelaysMs: []` keeps meaning "do not retry at all". +- **400 / 404 / 422 are never retried.** Retrying them only repeats the same + rejection ten times. +- **The message match is narrow.** It requires an explicit server-side or network + shape (`net::ERR_NETWORK_CHANGED`, `Internal server error (500)`, `502 Bad + Gateway`, `rate limit exceeded`, `HTTP 500`, …), so ordinary text such as + "used 400 tokens" or "step 404 done" is never mistaken for an error. + ## 0.7.0 Reliable delegation and result delivery Chat with your Bot as usual. It can ask other Bots for help while you keep chatting; completed results return here, and you can stop the work at any time. diff --git a/plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs b/plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs index ba356355..bbb28661 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs +++ b/plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs @@ -33,10 +33,67 @@ const HELPER_ACTIVE = new Set(['queued', 'running']); * A turn that failed before running any tool is retried this many more times, after these waits. * Model capacity errors (429/529) and timeouts usually clear within a minute. */ -const TURN_RETRY_DELAYS_MS = [8000, 20000]; +/** + * 重试窗口:**延迟序列 + 次数上限**(一个旋钮,两个含义 —— 与上游一致)。 + * + * ⚠️ 为什么改(2026-10-20):上游 0.7.4 只有 `[8000, 20000]` ⇒ **只重试 2 次** + * (等 8 秒、等 20 秒)⇒ 一次持续 30 秒的服务端抖动就让 bot **直接死掉**, + * 只能靠人手点一下才回来。 + * ⇒ 延长到 10 档:8s → 20s → 40s → 60s ×7(指数退避、封顶 60 秒), + * 总窗口约 **8 分钟**,足以自愈绝大多数瞬时抖动。 + * + * ⚠️ **仍然有上限**(不是无限重试):第 10 次之后就把原始错误暴露给用户。 + * ⚠️ **这个数组就是唯一的旋钮**:注入空数组 = 完全不重试(既有语义不变)。 + */ +const TURN_RETRY_DELAYS_MS = [8000, 20000, 40000, 60000, 60000, 60000, 60000, 60000, 60000, 60000]; /** Failure reasons a retry cannot fix; anything else (or no reason from an older Host) may be retried. */ const NO_RETRY_REASONS = new Set(['auth', 'quota', 'context_too_long', 'blocked', 'invalid_request']); +/** + * 暂时性错误判定:**只匹配明确的服务端 / 网络故障形态**。 + * + * 为什么需要它:Host 的 `toLLMRetryDecision()` 对**通用 5xx** 故意 fail-closed —— + * `LLM_UPSTREAM_ERROR`(50113)是「400 及以上的兜底桶」,把 HTTP 400/404 这种 + * 重试无意义的错误和 HTTP 500 这种**暂时性**错误混在一起,于是分类器只能整体 + * 判成不可重试 ⇒ 一次 30 秒的服务端抖动就会让 bot **直接死掉**。 + * + * ⚠️ **这里只认「明确是服务端/网络故障」的形态**(2026-10-20 收敛): + * - **不含 400 / 404 / 422**:Host 的注释说得很清楚 —— + * *would turn an HTTP 400 or 404 into ten identical attempts*。 + * 重试它们只是把同一次拒绝重复十遍。`invalid_request` 也在 `NO_RETRY_REASONS` 里。 + * - **不含裸数字**:否则「用了 400 tokens」「第 404 步」这类正常消息会被误判成错误。 + * 判定「这次失败值不值得重试」的**第一道**依据始终是 Host 自己给的 + * `result.error.retryable` 标志;这里只是补上 Host 判不出来的那一类。 + */ +const TRANSIENT_ERROR_PATTERNS = [ + /net::ERR_NETWORK_CHANGED/i, + /ERR_NETWORK/i, + /ECONNRESET|ECONNREFUSED|ETIMEDOUT|EPIPE/i, + /socket hang up/i, + /internal server error/i, + /bad gateway|gateway timeout|service unavailable/i, + /open platform service unavailable/i, + /请求处理超时/, + /unknown error, *520/, + /overloaded|rate limit|too many requests|model is busy/i, + // 5xx 只认带上下文的形态(`HTTP 500` / `status 502` / `code=503`), + // 不认裸数字 —— 「用了 500 tokens」不是错误。 + /\b(?:http|status(?:\s+code)?|code)\s*[:=]?\s*50[0234]\b/i, + /\b50[0234]\s+(bad gateway|gateway timeout|service unavailable|internal server error)/i, +]; + +/** + * 这条错误消息是不是「暂时性」的(重试有意义)。 + * + * @param {string} message + * @returns {boolean} + */ +export function isTransientErrorMessage(message) { + const text = String(message ?? ''); + if (!text) return false; + return TRANSIENT_ERROR_PATTERNS.some((re) => re.test(text)); +} + /** Resolves true after `ms`, or false at once if `signal` aborts first. */ function sleepUnlessAborted(ms, signal) { return new Promise((resolve) => { @@ -130,6 +187,7 @@ export class Engine { this.logger = deps.logger; /** Waits before each automatic retry of a failed turn (tests shorten them). */ this.retryDelays = deps.retryDelaysMs ?? TURN_RETRY_DELAYS_MS; + /** 无限重试的退避基数与上限(测试会把它调小)。 */ /** @type {{ at: number, value: Promise } | undefined} */ this.catalogCache = undefined; /** @type {Map} helper id -> run */ @@ -505,6 +563,10 @@ export class Engine { let failure = ''; let failureCode = ''; let failureRetryable = false; + // 原始错误消息(未翻译):暂时性错误的判定必须看**它**—— + // `failure` 是 `describeAgentError()` 翻译后的文案(可能是「模型不可用」这类 + // 泛化说法),里面已经没有 500 / 网络切换这些关键信息了。 + let rawFailureMessage = ''; let attempt = 0; try { for (;;) { @@ -536,31 +598,80 @@ export class Engine { failure = describeAgentError(retryError); failureCode = errorCode(retryError); failureRetryable = Boolean(/** @type {any} */ (retryError)?.retryable); + rawFailureMessage = errorText(retryError); } } else { failure = describeAgentError(error); failureCode = errorCode(error); failureRetryable = Boolean(/** @type {any} */ (error)?.retryable); + rawFailureMessage = errorText(error); } } const failed = Boolean(failure) ? failureRetryable : result?.status === 'error'; const reason = result?.error?.reason; + // ── 两条重试路径(2026-10-19 实测补)── + // + // A) **暂时性错误 ⇒ 无限重试**。 + // Host 把通用 5xx 归进 50113 这个「兜底桶」并 fail-closed, + // 但本插件看得到错误消息文本,能把「服务端抖了一下」与 + // 「服务端明确拒绝」分开。实测这批 50113 全是暂时性故障 + // (网络切换 / 500 / 超时 / 520 / 502 / 服务不可用)。 + // ⇒ 一直重试到成功,退避封顶 60 秒。 + // + // B) **其它可重试错误 ⇒ 有限重试**(原有行为,2 次 / 28 秒窗口)。 + // + // 两条路都排除:额度类(quota)、鉴权类(auth)、上下文过长、被拒、 + // 参数非法 —— 这些重试一万次也没用。 + // ⚠️ 成功后必须**立刻停止**重试。 + // `rawFailureMessage` 保留着上一次失败的内容,而 `transient` 只看它 —— + // 于是第 4 次已经 `result.status === 'finished'` 了,却因为「上一次是 + // 暂时性错误」而继续重试(实测 attempts 从 4 一路涨到 7,永远停不下来)。 + // ⇒ 判据必须先看**本次**的结果是否成功。 + const succeeded = result?.status === 'finished'; + const transient = + !succeeded && + isTransientErrorMessage(result?.error?.message ?? rawFailureMessage ?? ''); + // ⚠️ 抛异常的路径里 `result` 是 undefined,所以不能用 `result?.status`。 + // 那种情况下「暂时性」由**原始错误消息**判定: + // 抛异常 ⇒ rawFailureMessage 有内容 ⇒ transient 为真 ⇒ 可重试。 + // ⚠️ 「暂时性」**不再**用来绕过下面两道判据: + // - 不再绕过 `NO_RETRY_REASONS`(auth / quota / invalid_request 等) + // - 不再绕过次数上限(见 `TURN_RETRY_MAX_ATTEMPTS`) + // 它只用来决定**退避用多长**。 + const retryableFailure = + failed || + (transient && + !NO_RETRY_REASONS.has(reason) && + reason !== 'auth' && + reason !== 'quota'); const canRetry = - failed && + retryableFailure && !input.answer && !toolStarted && + // ⚠️ **有上限**(2026-10-20 收敛):暂时性错误**不再**无限重试。 + // 次数上限就是 `this.retryDelays.length`(默认 10 档 ⇒ 约 7.5 分钟窗口), + // 注入空数组仍然表示「完全不重试」(既有语义一字未改)。 attempt < this.retryDelays.length && !controller.signal.aborted && !this.disposed && result?.error?.retryable !== false && !NO_RETRY_REASONS.has(reason); if (!canRetry) break; - const delay = this.retryDelays[attempt]; + // 延迟 = 窗口里的那一档(可注入 ⇒ 既有语义与既有测试都不受影响)。 + const delay = this.retryDelays[attempt] ?? 0; attempt += 1; // Words the failed attempt streamed are not the reply; the retry writes its own. message.parts = message.parts.filter((part) => part.type !== 'text'); liveText = undefined; - message.live = { key: 'live.retrying', params: { s: Math.round(delay / 1000), n: attempt + 1, max: this.retryDelays.length + 1 } }; + message.live = { + key: 'live.retrying', + params: { + s: Math.round(delay / 1000), + n: attempt + 1, + // 有次数上限 ⇒ 如实告诉用户还剩几次机会。 + max: this.retryMaxAttempts + 1, + }, + }; flush(true); const waited = await sleepUnlessAborted(delay, controller.signal); message.live = undefined; diff --git a/plugins/minimax-ai/minimax-bot/tests/turn-retry-backoff.test.mjs b/plugins/minimax-ai/minimax-bot/tests/turn-retry-backoff.test.mjs new file mode 100644 index 00000000..69592016 --- /dev/null +++ b/plugins/minimax-ai/minimax-bot/tests/turn-retry-backoff.test.mjs @@ -0,0 +1,70 @@ +// 回合级重试:重试窗口 + 暂时性错误判定。 +// +// 2026-10-20 收敛(依据上游 review 意见): +// - 上游 0.7.4 的窗口是 `[8000, 20000]` ⇒ **只重试 2 次** +// ⇒ 一次持续 30 秒的服务端抖动就让 bot **直接死掉**,只能靠人手点一下。 +// - 但「无限重试」又与维护者「给连续重试设个上限」的要求相反。 +// ⇒ 收敛成:**默认窗口延长到 10 档(8s→20s→40s→60s×6,约 7.5 分钟)**,仍然有上限。 +// +// ⚠️ 同时守住两条边界: +// 1. 400 / 404 / 422 **不重试**(重试它们只是把同一次拒绝重复十遍) +// 2. 正常消息(「用了 400 tokens」「第 404 步」)不会被误判成错误 +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { isTransientErrorMessage } from '../miniapp/node/engine.mjs'; + +/** 与 engine.mjs 里的默认窗口保持一致(改一处就要同步这里)。 */ +const DEFAULT_WINDOW_MS = [8000, 20000, 40000, 60000, 60000, 60000, 60000, 60000, 60000, 60000]; + +test('默认重试窗口:10 档、指数退避、封顶 60 秒,总窗口约 8 分钟', () => { + assert.equal(DEFAULT_WINDOW_MS.length, 10, '必须有明确的上限(不是无限重试)'); + assert.equal(DEFAULT_WINDOW_MS[0], 8000); + assert.equal(DEFAULT_WINDOW_MS[1], 20000); + assert.equal(DEFAULT_WINDOW_MS[2], 40000); + for (const d of DEFAULT_WINDOW_MS.slice(3)) assert.equal(d, 60000, '之后一律封顶 60s'); + const total = DEFAULT_WINDOW_MS.reduce((a, b) => a + b, 0); + assert.ok(total >= 7 * 60_000 && total <= 9 * 60_000, `总窗口应约 8 分钟,实际 ${total}ms`); +}); + +test('暂时性错误会被认出来(服务端 / 网络故障)', () => { + for (const m of [ + 'net::ERR_NETWORK_CHANGED', + 'Internal server error (500)', + '502 Bad Gateway', + 'service unavailable', + '请求处理超时 (2066)', + 'unknown error, 520 (1000)', + 'rate limit exceeded', + 'The model is busy right now', + 'HTTP 500', + 'status 502', + ]) { + assert.equal(isTransientErrorMessage(m), true, `应判为暂时性:${m}`); + } +}); + +test('400 / 404 / 422 不重试(重试只是把同一次拒绝重复十遍)', () => { + for (const m of [ + 'HTTP 400 Bad Request', + 'HTTP 404 Not Found', + 'code=422', + '400 Bad Request', + '404 Not Found', + ]) { + assert.equal(isTransientErrorMessage(m), false, `不该重试:${m}`); + } +}); + +test('正常消息不会被误判成错误', () => { + for (const m of [ + '用了 400 tokens', + '第 404 步完成', + '剩余 422 积分', + '当前已达到 Token Plan 用量上限 (2067)', + '积分已耗尽', + '', + undefined, + ]) { + assert.equal(isTransientErrorMessage(m), false, `不该判为暂时性:${m}`); + } +}); From 9a42b99cb15bdd01452ae7fc23c2e0dafe9f6287 Mon Sep 17 00:00:00 2001 From: Hylouis233 Date: Wed, 21 Oct 2026 11:49:38 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(minimax-bot):=20=E4=BF=AE=20max=20?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E6=88=90=202/NaN=E3=80=81=E6=B8=85=E6=8E=89?= =?UTF-8?q?=E8=BF=87=E6=9C=9F=E7=9A=84=E3=80=8C=E6=97=A0=E9=99=90=E9=87=8D?= =?UTF-8?q?=E8=AF=95=E3=80=8D=E6=B3=A8=E9=87=8A=E3=80=81=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=8F=B7=E4=B8=89=E5=A4=84=E5=AF=B9=E9=BD=90=200.7.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 维护者 review 指出的问题: 1. **`max` 显示成 `2/NaN`**(真 bug,我引入的): 删掉 `this.retryMaxAttempts` 的赋值时漏改了**用法**, `this.retryMaxAttempts + 1` ⇒ `undefined + 1` = NaN。 ⇒ 改用 `this.retryDelays.length + 1`(与上游原本的写法一致)。 2. **清掉还写着「无限重试」的旧注释** —— 现在统一口径: 暂时性错误**同样受次数上限约束**(第 10 次之后把原始错误暴露给用户)。 3. **版本号三处对齐 0.7.5**:`plugin.json` / `package.json` / `mcp.mjs` 的 `serverInfo.version` 之前分别是 0.7.5 / 0.7.4 / 0.7.0。 (与 #48 的 0.7.5 撞号是预期的,两边都需要维护者定顺序。) 测试:121 项 / 114 通过 / 7 失败 —— 与上游 main 完全相同(那 7 个是上游自己的失败)。 --- plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs | 10 +++++----- plugins/minimax-ai/minimax-bot/miniapp/node/mcp.mjs | 2 +- plugins/minimax-ai/minimax-bot/package.json | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs b/plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs index bbb28661..678da493 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs +++ b/plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs @@ -42,7 +42,7 @@ const HELPER_ACTIVE = new Set(['queued', 'running']); * ⇒ 延长到 10 档:8s → 20s → 40s → 60s ×7(指数退避、封顶 60 秒), * 总窗口约 **8 分钟**,足以自愈绝大多数瞬时抖动。 * - * ⚠️ **仍然有上限**(不是无限重试):第 10 次之后就把原始错误暴露给用户。 + * ⚠️ **有上限**:第 10 次之后就把原始错误暴露给用户,不让它一直转下去。 * ⚠️ **这个数组就是唯一的旋钮**:注入空数组 = 完全不重试(既有语义不变)。 */ const TURN_RETRY_DELAYS_MS = [8000, 20000, 40000, 60000, 60000, 60000, 60000, 60000, 60000, 60000]; @@ -187,7 +187,7 @@ export class Engine { this.logger = deps.logger; /** Waits before each automatic retry of a failed turn (tests shorten them). */ this.retryDelays = deps.retryDelaysMs ?? TURN_RETRY_DELAYS_MS; - /** 无限重试的退避基数与上限(测试会把它调小)。 */ + /** 重试窗口的退避基数与上限(测试会把它调小)。 */ /** @type {{ at: number, value: Promise } | undefined} */ this.catalogCache = undefined; /** @type {Map} helper id -> run */ @@ -611,7 +611,7 @@ export class Engine { const reason = result?.error?.reason; // ── 两条重试路径(2026-10-19 实测补)── // - // A) **暂时性错误 ⇒ 无限重试**。 + // A) **暂时性错误 ⇒ 值得重试**(但仍然受次数上限约束)。 // Host 把通用 5xx 归进 50113 这个「兜底桶」并 fail-closed, // 但本插件看得到错误消息文本,能把「服务端抖了一下」与 // 「服务端明确拒绝」分开。实测这批 50113 全是暂时性故障 @@ -648,7 +648,7 @@ export class Engine { retryableFailure && !input.answer && !toolStarted && - // ⚠️ **有上限**(2026-10-20 收敛):暂时性错误**不再**无限重试。 + // ⚠️ **有上限**(2026-10-20 收敛):暂时性错误同样受次数上限约束。 // 次数上限就是 `this.retryDelays.length`(默认 10 档 ⇒ 约 7.5 分钟窗口), // 注入空数组仍然表示「完全不重试」(既有语义一字未改)。 attempt < this.retryDelays.length && @@ -669,7 +669,7 @@ export class Engine { s: Math.round(delay / 1000), n: attempt + 1, // 有次数上限 ⇒ 如实告诉用户还剩几次机会。 - max: this.retryMaxAttempts + 1, + max: this.retryDelays.length + 1, }, }; flush(true); diff --git a/plugins/minimax-ai/minimax-bot/miniapp/node/mcp.mjs b/plugins/minimax-ai/minimax-bot/miniapp/node/mcp.mjs index ac948fea..25536f62 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/node/mcp.mjs +++ b/plugins/minimax-ai/minimax-bot/miniapp/node/mcp.mjs @@ -586,7 +586,7 @@ export function createMcpHandler({ team, engine }) { result = { protocolVersion: PROTOCOL_VERSIONS.includes(requested) ? requested : PROTOCOL_VERSIONS[0], capabilities: { tools: { listChanged: false } }, - serverInfo: { name: 'minimax-bot', version: '0.7.0' }, + serverInfo: { name: 'minimax-bot', version: '0.7.5' }, instructions: t('mcp.instructions'), }; break; diff --git a/plugins/minimax-ai/minimax-bot/package.json b/plugins/minimax-ai/minimax-bot/package.json index aee5aa8d..9584fc98 100644 --- a/plugins/minimax-ai/minimax-bot/package.json +++ b/plugins/minimax-ai/minimax-bot/package.json @@ -1,6 +1,6 @@ { "name": "minimax-bot", - "version": "0.7.4", + "version": "0.7.5", "private": true, "type": "module", "mcode": { From 9c217dd85789fa7959bcf5a4b960632c32901e93 Mon Sep 17 00:00:00 2001 From: Hong Liu <88263959+Hylouis233@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:38:47 +0800 Subject: [PATCH 3/4] fix(minimax-bot): veto retries on retryable:false and unknown outcomes, keep the default window - An explicit `retryable: false` now ends the turn on both the thrown and the returned path, and `AGENT_OUTCOME_UNKNOWN` is never retried automatically. - Drop the error-text matching; the retry decision uses only the structured `retryable`, `reason` and `code`. - Keep the default window at two retries (8 s, 20 s). The ten-step window (about 8 minutes) is now opt-in through the `longRetry` setting. - Remove references to Host internals from comments and the README, and tidy the retry comments. - Test the real exported windows and the engine's retry loop, including the thrown `AGENT_OUTCOME_UNKNOWN` + `retryable: false` case. --- plugins/minimax-ai/minimax-bot/README.md | 36 +--- .../minimax-bot/miniapp/node/engine.mjs | 159 ++++-------------- .../minimax-bot/miniapp/node/server.mjs | 2 + .../minimax-bot/miniapp/node/store.mjs | 4 +- .../tests/turn-retry-backoff.test.mjs | 141 +++++++++------- .../tests/turn-retry-veto.test.mjs | 118 +++++++++++++ 6 files changed, 244 insertions(+), 216 deletions(-) create mode 100644 plugins/minimax-ai/minimax-bot/tests/turn-retry-veto.test.mjs diff --git a/plugins/minimax-ai/minimax-bot/README.md b/plugins/minimax-ai/minimax-bot/README.md index 5a1a4697..d326c672 100644 --- a/plugins/minimax-ai/minimax-bot/README.md +++ b/plugins/minimax-ai/minimax-bot/README.md @@ -30,36 +30,10 @@ Copy this directory, including the hidden `.minimax-plugin/` directory, into the - **Reply actions**: the reaction, copy, quote-reply and open-session buttons sit at the bottom-left of a reply, under its last line. A long reply therefore keeps its actions within reach at the end of the text instead of pushing them off the top of the message. The reaction indicator sits directly above that row and is painted with the app's own tag colours, so it follows the light and dark themes. - **Working messages of one reply**: the Host records a text part for every turn a Bot takes between two tool calls, so a reply that worked for a while arrives with many parts, and the page draws one bubble per part. The bubbles keep the official shape and order, and a small gap between them tells them apart instead of letting them read as one block of text. -## 0.7.8 Transient failures get a longer, still-capped retry window - -A Bot that died from a **temporary** failure now comes back on its own. - -The Host classifies a generic 5xx as `LLM_UPSTREAM_ERROR` (50113) and deliberately -fails closed on it: that code is a catch-all bucket for *any* status of 400 or above -except 402/401/403/429/529, so retrying it blindly could turn an HTTP 400 or 404 -into ten identical attempts. The result is that a plain HTTP 500 — which usually -clears on its own within seconds — killed the turn on the first try. - -This plugin sees the error **message**, so it can tell the two apart. Failures that -name a transient condition (a network error, a 5xx, a gateway timeout, rate limiting, -an overloaded model) are retried; failures that are a decision rather than a glitch -are not. - -What changed in 0.7.8: - -- **The retry window is longer.** Earlier versions retried **twice** (after 8 s and 20 s), so a - blip that lasted 30 seconds killed the Bot. The window is now - `8s → 20s → 40s → 60s × 7` — ten attempts over roughly **eight minutes**, which - covers the overwhelming majority of transient outages. -- **There is still a cap.** After the last attempt the original error surfaces as - it is. The window is also still the single knob: injecting - `retryDelaysMs: []` keeps meaning "do not retry at all". -- **400 / 404 / 422 are never retried.** Retrying them only repeats the same - rejection ten times. -- **The message match is narrow.** It requires an explicit server-side or network - shape (`net::ERR_NETWORK_CHANGED`, `Internal server error (500)`, `502 Bad - Gateway`, `rate limit exceeded`, `HTTP 500`, …), so ordinary text such as - "used 400 tokens" or "step 404 done" is never mistaken for an error. +## 0.7.8 An optional longer retry window, and stricter retry rules + +- **Longer retry window, off by default.** A failed turn is still retried twice (after 8 s and 20 s). Turning on `longRetry` in the settings (`PUT /api/settings` with `{"longRetry": true}`) retries it up to ten times instead, backing off 8 s, 20 s, 40 s and then 60 s, about 8 minutes in all. After the last retry the original error is shown. +- **A turn whose outcome is unknown is never retried.** `AGENT_OUTCOME_UNKNOWN` means the turn may already have run, so sending it again could repeat its reply or its actions. Otherwise what is retried is unchanged: only a turn that failed before running any tool, and never a failure explicitly marked as not retryable, whether it was thrown or returned. ## 0.7.0 Reliable delegation and result delivery @@ -96,7 +70,7 @@ Avatars are stored in the Mini App's `dataDir/avatars/`. `GET /api/avatars` list - Full history is kept locally. The page loads the latest 200 messages first and can page back through older ones; Copy as Markdown covers the whole history. - Messages carry stable increasing sequence numbers. A group member only advances past the snapshot that was actually submitted to the host, so messages arriving during its reply are not skipped. - Sending is de-duplicated per chat, and retries of the same content and attachments reuse the same submission ID. After a crash, only requests that clearly never started are recovered; when a reply placeholder exists but the host outcome is unknown, the request is reported as `delivery_uncertain` and the draft is kept, so tasks with side effects are not run twice. -- Failed turns are retried automatically (after 8 s and 20 s), and a retry button is shown afterwards; if a message never reached the Bot, retry resends it. +- Failed turns are retried automatically (after 8 s and 20 s, or for about 8 minutes with the `longRetry` setting), and a retry button is shown afterwards; if a message never reached the Bot, retry resends it. - Memory writes use a recoverable transaction covering the entry and its journal; an interrupted write returns `memory_pending` and is completed by a read, retry or restart without double counting. Before the first change to an existing scope, the original file is backed up to `dataDir/memory/backups/`. ## 0.6.5 Execution records and outcome notices diff --git a/plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs b/plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs index 678da493..f5a05dfc 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs +++ b/plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs @@ -30,69 +30,22 @@ const MAX_HELPERS_PER_BOT = 4; const HELPER_REPORT_CLIP = 8000; const HELPER_ACTIVE = new Set(['queued', 'running']); /** - * A turn that failed before running any tool is retried this many more times, after these waits. - * Model capacity errors (429/529) and timeouts usually clear within a minute. + * A turn that failed before running any tool is retried this many more times, after these waits + * (2 retries, about 28 seconds in all). Model capacity errors (429/529) and timeouts usually + * clear within a minute. */ +export const TURN_RETRY_DELAYS_MS = Object.freeze([8000, 20000]); /** - * 重试窗口:**延迟序列 + 次数上限**(一个旋钮,两个含义 —— 与上游一致)。 - * - * ⚠️ 为什么改(2026-10-20):上游 0.7.4 只有 `[8000, 20000]` ⇒ **只重试 2 次** - * (等 8 秒、等 20 秒)⇒ 一次持续 30 秒的服务端抖动就让 bot **直接死掉**, - * 只能靠人手点一下才回来。 - * ⇒ 延长到 10 档:8s → 20s → 40s → 60s ×7(指数退避、封顶 60 秒), - * 总窗口约 **8 分钟**,足以自愈绝大多数瞬时抖动。 - * - * ⚠️ **有上限**:第 10 次之后就把原始错误暴露给用户,不让它一直转下去。 - * ⚠️ **这个数组就是唯一的旋钮**:注入空数组 = 完全不重试(既有语义不变)。 + * The longer retry window, used only when the user turns on `settings.longRetry`: 10 retries + * backing off 8 s, 20 s, 40 s, then 60 s seven times, 488 seconds (about 8 minutes) in all. + * It retries the same failures as the default window, only for longer; after the last retry the + * original error is shown. */ -const TURN_RETRY_DELAYS_MS = [8000, 20000, 40000, 60000, 60000, 60000, 60000, 60000, 60000, 60000]; +export const LONG_TURN_RETRY_DELAYS_MS = Object.freeze([8000, 20000, 40000, 60000, 60000, 60000, 60000, 60000, 60000, 60000]); /** Failure reasons a retry cannot fix; anything else (or no reason from an older Host) may be retried. */ const NO_RETRY_REASONS = new Set(['auth', 'quota', 'context_too_long', 'blocked', 'invalid_request']); - -/** - * 暂时性错误判定:**只匹配明确的服务端 / 网络故障形态**。 - * - * 为什么需要它:Host 的 `toLLMRetryDecision()` 对**通用 5xx** 故意 fail-closed —— - * `LLM_UPSTREAM_ERROR`(50113)是「400 及以上的兜底桶」,把 HTTP 400/404 这种 - * 重试无意义的错误和 HTTP 500 这种**暂时性**错误混在一起,于是分类器只能整体 - * 判成不可重试 ⇒ 一次 30 秒的服务端抖动就会让 bot **直接死掉**。 - * - * ⚠️ **这里只认「明确是服务端/网络故障」的形态**(2026-10-20 收敛): - * - **不含 400 / 404 / 422**:Host 的注释说得很清楚 —— - * *would turn an HTTP 400 or 404 into ten identical attempts*。 - * 重试它们只是把同一次拒绝重复十遍。`invalid_request` 也在 `NO_RETRY_REASONS` 里。 - * - **不含裸数字**:否则「用了 400 tokens」「第 404 步」这类正常消息会被误判成错误。 - * 判定「这次失败值不值得重试」的**第一道**依据始终是 Host 自己给的 - * `result.error.retryable` 标志;这里只是补上 Host 判不出来的那一类。 - */ -const TRANSIENT_ERROR_PATTERNS = [ - /net::ERR_NETWORK_CHANGED/i, - /ERR_NETWORK/i, - /ECONNRESET|ECONNREFUSED|ETIMEDOUT|EPIPE/i, - /socket hang up/i, - /internal server error/i, - /bad gateway|gateway timeout|service unavailable/i, - /open platform service unavailable/i, - /请求处理超时/, - /unknown error, *520/, - /overloaded|rate limit|too many requests|model is busy/i, - // 5xx 只认带上下文的形态(`HTTP 500` / `status 502` / `code=503`), - // 不认裸数字 —— 「用了 500 tokens」不是错误。 - /\b(?:http|status(?:\s+code)?|code)\s*[:=]?\s*50[0234]\b/i, - /\b50[0234]\s+(bad gateway|gateway timeout|service unavailable|internal server error)/i, -]; - -/** - * 这条错误消息是不是「暂时性」的(重试有意义)。 - * - * @param {string} message - * @returns {boolean} - */ -export function isTransientErrorMessage(message) { - const text = String(message ?? ''); - if (!text) return false; - return TRANSIENT_ERROR_PATTERNS.some((re) => re.test(text)); -} +/** The turn may already have run, so sending it again could repeat its reply or its actions. */ +const OUTCOME_UNKNOWN = 'AGENT_OUTCOME_UNKNOWN'; /** Resolves true after `ms`, or false at once if `signal` aborts first. */ function sleepUnlessAborted(ms, signal) { @@ -178,6 +131,8 @@ export class Engine { * agent: MiniAppAgentClient | undefined, * logger: import('./miniapp-api.js').MiniAppLogger, * broadcast: (event: Record) => void, + * retryDelaysMs?: readonly number[], + * longRetryDelaysMs?: readonly number[], * }} deps */ constructor(deps) { @@ -187,7 +142,8 @@ export class Engine { this.logger = deps.logger; /** Waits before each automatic retry of a failed turn (tests shorten them). */ this.retryDelays = deps.retryDelaysMs ?? TURN_RETRY_DELAYS_MS; - /** 重试窗口的退避基数与上限(测试会把它调小)。 */ + /** Waits used instead when `settings.longRetry` is on (tests shorten them). */ + this.longRetryDelays = deps.longRetryDelaysMs ?? LONG_TURN_RETRY_DELAYS_MS; /** @type {{ at: number, value: Promise } | undefined} */ this.catalogCache = undefined; /** @type {Map} helper id -> run */ @@ -367,6 +323,11 @@ export class Engine { return `${this.buildStandingBrief(bot, chatId)}\n\n${await this.buildMemoryBrief(bot, query)}`; } + /** The waits before each automatic retry of a failed turn: the long window only when the user turned it on. */ + turnRetryDelays() { + return this.store.state.settings?.longRetry === true ? this.longRetryDelays : this.retryDelays; + } + // ------------------------------------------------------------------ core turn /** * Run one turn for a Bot in a chat and stream it into a new Bot message. @@ -563,11 +524,9 @@ export class Engine { let failure = ''; let failureCode = ''; let failureRetryable = false; - // 原始错误消息(未翻译):暂时性错误的判定必须看**它**—— - // `failure` 是 `describeAgentError()` 翻译后的文案(可能是「模型不可用」这类 - // 泛化说法),里面已经没有 500 / 网络切换这些关键信息了。 - let rawFailureMessage = ''; let attempt = 0; + // Fixed for the whole turn, so the cap and the "n of max" shown while retrying agree. + const retryDelays = this.turnRetryDelays(); try { for (;;) { result = undefined; @@ -597,81 +556,37 @@ export class Engine { } catch (retryError) { failure = describeAgentError(retryError); failureCode = errorCode(retryError); - failureRetryable = Boolean(/** @type {any} */ (retryError)?.retryable); - rawFailureMessage = errorText(retryError); + failureRetryable = /** @type {any} */ (retryError)?.retryable === true; } } else { failure = describeAgentError(error); failureCode = errorCode(error); - failureRetryable = Boolean(/** @type {any} */ (error)?.retryable); - rawFailureMessage = errorText(error); + failureRetryable = /** @type {any} */ (error)?.retryable === true; } } - const failed = Boolean(failure) ? failureRetryable : result?.status === 'error'; + // A thrown error is retried only when it says it is retryable; a failed result is retried + // unless it says it is not. Either way an explicit `retryable: false`, an outcome that may + // already have run, or a reason a retry cannot fix ends the turn. + const thrown = Boolean(failure); const reason = result?.error?.reason; - // ── 两条重试路径(2026-10-19 实测补)── - // - // A) **暂时性错误 ⇒ 值得重试**(但仍然受次数上限约束)。 - // Host 把通用 5xx 归进 50113 这个「兜底桶」并 fail-closed, - // 但本插件看得到错误消息文本,能把「服务端抖了一下」与 - // 「服务端明确拒绝」分开。实测这批 50113 全是暂时性故障 - // (网络切换 / 500 / 超时 / 520 / 502 / 服务不可用)。 - // ⇒ 一直重试到成功,退避封顶 60 秒。 - // - // B) **其它可重试错误 ⇒ 有限重试**(原有行为,2 次 / 28 秒窗口)。 - // - // 两条路都排除:额度类(quota)、鉴权类(auth)、上下文过长、被拒、 - // 参数非法 —— 这些重试一万次也没用。 - // ⚠️ 成功后必须**立刻停止**重试。 - // `rawFailureMessage` 保留着上一次失败的内容,而 `transient` 只看它 —— - // 于是第 4 次已经 `result.status === 'finished'` 了,却因为「上一次是 - // 暂时性错误」而继续重试(实测 attempts 从 4 一路涨到 7,永远停不下来)。 - // ⇒ 判据必须先看**本次**的结果是否成功。 - const succeeded = result?.status === 'finished'; - const transient = - !succeeded && - isTransientErrorMessage(result?.error?.message ?? rawFailureMessage ?? ''); - // ⚠️ 抛异常的路径里 `result` 是 undefined,所以不能用 `result?.status`。 - // 那种情况下「暂时性」由**原始错误消息**判定: - // 抛异常 ⇒ rawFailureMessage 有内容 ⇒ transient 为真 ⇒ 可重试。 - // ⚠️ 「暂时性」**不再**用来绕过下面两道判据: - // - 不再绕过 `NO_RETRY_REASONS`(auth / quota / invalid_request 等) - // - 不再绕过次数上限(见 `TURN_RETRY_MAX_ATTEMPTS`) - // 它只用来决定**退避用多长**。 - const retryableFailure = - failed || - (transient && - !NO_RETRY_REASONS.has(reason) && - reason !== 'auth' && - reason !== 'quota'); + const failed = thrown ? failureRetryable : result?.status === 'error' && result?.error?.retryable !== false; + const outcomeUnknown = failureCode === OUTCOME_UNKNOWN || result?.error?.code === OUTCOME_UNKNOWN; const canRetry = - retryableFailure && + failed && + !outcomeUnknown && + !NO_RETRY_REASONS.has(reason) && !input.answer && !toolStarted && - // ⚠️ **有上限**(2026-10-20 收敛):暂时性错误同样受次数上限约束。 - // 次数上限就是 `this.retryDelays.length`(默认 10 档 ⇒ 约 7.5 分钟窗口), - // 注入空数组仍然表示「完全不重试」(既有语义一字未改)。 - attempt < this.retryDelays.length && + attempt < retryDelays.length && !controller.signal.aborted && - !this.disposed && - result?.error?.retryable !== false && - !NO_RETRY_REASONS.has(reason); + !this.disposed; if (!canRetry) break; - // 延迟 = 窗口里的那一档(可注入 ⇒ 既有语义与既有测试都不受影响)。 - const delay = this.retryDelays[attempt] ?? 0; + const delay = retryDelays[attempt]; attempt += 1; // Words the failed attempt streamed are not the reply; the retry writes its own. message.parts = message.parts.filter((part) => part.type !== 'text'); liveText = undefined; - message.live = { - key: 'live.retrying', - params: { - s: Math.round(delay / 1000), - n: attempt + 1, - // 有次数上限 ⇒ 如实告诉用户还剩几次机会。 - max: this.retryDelays.length + 1, - }, - }; + message.live = { key: 'live.retrying', params: { s: Math.round(delay / 1000), n: attempt + 1, max: retryDelays.length + 1 } }; flush(true); const waited = await sleepUnlessAborted(delay, controller.signal); message.live = undefined; diff --git a/plugins/minimax-ai/minimax-bot/miniapp/node/server.mjs b/plugins/minimax-ai/minimax-bot/miniapp/node/server.mjs index 9e27e240..8e6dc70d 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/node/server.mjs +++ b/plugins/minimax-ai/minimax-bot/miniapp/node/server.mjs @@ -516,6 +516,8 @@ export async function start(context) { if (name.length > USER_NAME_MAX) throw new EngineError('invalid', t('err.userNameTooLong', { max: USER_NAME_MAX })); next.userName = name; } + // Off by default: retry a failed turn for about 8 minutes instead of about 28 seconds. + if (body.longRetry !== undefined) next.longRetry = body.longRetry === true; store.state.settings = next; if (body.language !== undefined) await team.applyLocale(); else { diff --git a/plugins/minimax-ai/minimax-bot/miniapp/node/store.mjs b/plugins/minimax-ai/minimax-bot/miniapp/node/store.mjs index 6dc837ed..77b38590 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/node/store.mjs +++ b/plugins/minimax-ai/minimax-bot/miniapp/node/store.mjs @@ -151,8 +151,8 @@ function defaultState() { /** Background helpers: queued / running ones and the most recent finished ones. */ helpers: [], taskBatches: [], - /** `language`: 'auto' (follow the OS) | 'zh' | 'en' */ - settings: { language: 'auto', userName: '' }, + /** `language`: 'auto' (follow the OS) | 'zh' | 'en'. `longRetry`: retry failed turns for about 8 minutes instead of about 28 seconds. */ + settings: { language: 'auto', userName: '', longRetry: false }, /** OS language last reported by the page, used to resolve `auto`. */ systemLocale: undefined, }; diff --git a/plugins/minimax-ai/minimax-bot/tests/turn-retry-backoff.test.mjs b/plugins/minimax-ai/minimax-bot/tests/turn-retry-backoff.test.mjs index 69592016..4f85581c 100644 --- a/plugins/minimax-ai/minimax-bot/tests/turn-retry-backoff.test.mjs +++ b/plugins/minimax-ai/minimax-bot/tests/turn-retry-backoff.test.mjs @@ -1,70 +1,89 @@ -// 回合级重试:重试窗口 + 暂时性错误判定。 -// -// 2026-10-20 收敛(依据上游 review 意见): -// - 上游 0.7.4 的窗口是 `[8000, 20000]` ⇒ **只重试 2 次** -// ⇒ 一次持续 30 秒的服务端抖动就让 bot **直接死掉**,只能靠人手点一下。 -// - 但「无限重试」又与维护者「给连续重试设个上限」的要求相反。 -// ⇒ 收敛成:**默认窗口延长到 10 档(8s→20s→40s→60s×6,约 7.5 分钟)**,仍然有上限。 -// -// ⚠️ 同时守住两条边界: -// 1. 400 / 404 / 422 **不重试**(重试它们只是把同一次拒绝重复十遍) -// 2. 正常消息(「用了 400 tokens」「第 404 步」)不会被误判成错误 -import { test } from 'node:test'; +// The automatic retry windows of a failed turn: the real exported defaults, and how the engine +// picks and applies them. import assert from 'node:assert/strict'; -import { isTransientErrorMessage } from '../miniapp/node/engine.mjs'; +import { test } from 'node:test'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Store } from '../miniapp/node/store.mjs'; +import { MemoryStore } from '../miniapp/node/memory.mjs'; +import { Engine, LONG_TURN_RETRY_DELAYS_MS, TURN_RETRY_DELAYS_MS } from '../miniapp/node/engine.mjs'; +import { setLocale } from '../miniapp/node/i18n.mjs'; -/** 与 engine.mjs 里的默认窗口保持一致(改一处就要同步这里)。 */ -const DEFAULT_WINDOW_MS = [8000, 20000, 40000, 60000, 60000, 60000, 60000, 60000, 60000, 60000]; +async function fixture(t, deps = {}) { + const dir = await mkdtemp(join(tmpdir(), 'bot-turn-retry-window-')); + const store = new Store(dir); + const memory = new MemoryStore(dir); + await store.init(); + await memory.init(); + setLocale('en'); + const events = []; + const engine = new Engine({ store, memory, logger: { warn() {} }, broadcast: (event) => events.push(structuredClone(event)), ...deps }); + t.after(async () => { + engine.dispose(); + await store.writeChain; + await rm(dir, { recursive: true, force: true }); + }); + return { store, engine, events }; +} -test('默认重试窗口:10 档、指数退避、封顶 60 秒,总窗口约 8 分钟', () => { - assert.equal(DEFAULT_WINDOW_MS.length, 10, '必须有明确的上限(不是无限重试)'); - assert.equal(DEFAULT_WINDOW_MS[0], 8000); - assert.equal(DEFAULT_WINDOW_MS[1], 20000); - assert.equal(DEFAULT_WINDOW_MS[2], 40000); - for (const d of DEFAULT_WINDOW_MS.slice(3)) assert.equal(d, 60000, '之后一律封顶 60s'); - const total = DEFAULT_WINDOW_MS.reduce((a, b) => a + b, 0); - assert.ok(total >= 7 * 60_000 && total <= 9 * 60_000, `总窗口应约 8 分钟,实际 ${total}ms`); -}); +/** An Agent whose every turn starts and then fails with no retry verdict. */ +function alwaysFailing(calls) { + return { + async run(input, options) { + calls.count += 1; + options.onEvent({ type: 'turn.started', sessionId: 's1', turnId: `t${calls.count}` }); + return { sessionId: 's1', turnId: `t${calls.count}`, status: 'error', error: { code: 'TURN_FAILED', message: 'The Agent turn failed.' } }; + }, + }; +} -test('暂时性错误会被认出来(服务端 / 网络故障)', () => { - for (const m of [ - 'net::ERR_NETWORK_CHANGED', - 'Internal server error (500)', - '502 Bad Gateway', - 'service unavailable', - '请求处理超时 (2066)', - 'unknown error, 520 (1000)', - 'rate limit exceeded', - 'The model is busy right now', - 'HTTP 500', - 'status 502', - ]) { - assert.equal(isTransientErrorMessage(m), true, `应判为暂时性:${m}`); - } +test('the default window is two retries after 8 s and 20 s, and a new engine uses it', async (t) => { + assert.deepEqual(TURN_RETRY_DELAYS_MS, [8000, 20000]); + assert.ok(Object.isFrozen(TURN_RETRY_DELAYS_MS)); + const { store, engine } = await fixture(t); + assert.equal(store.state.settings.longRetry, false); + assert.equal(engine.turnRetryDelays(), TURN_RETRY_DELAYS_MS); }); -test('400 / 404 / 422 不重试(重试只是把同一次拒绝重复十遍)', () => { - for (const m of [ - 'HTTP 400 Bad Request', - 'HTTP 404 Not Found', - 'code=422', - '400 Bad Request', - '404 Not Found', - ]) { - assert.equal(isTransientErrorMessage(m), false, `不该重试:${m}`); - } +test('the long window is ten capped retries, 488 seconds in all, used only when the setting is on', async (t) => { + assert.deepEqual(LONG_TURN_RETRY_DELAYS_MS, [8000, 20000, 40000, 60000, 60000, 60000, 60000, 60000, 60000, 60000]); + assert.ok(Object.isFrozen(LONG_TURN_RETRY_DELAYS_MS)); + assert.equal(LONG_TURN_RETRY_DELAYS_MS.reduce((sum, ms) => sum + ms, 0), 488_000); + const { store, engine } = await fixture(t); + store.state.settings.longRetry = true; + assert.equal(engine.turnRetryDelays(), LONG_TURN_RETRY_DELAYS_MS); + store.state.settings.longRetry = false; + assert.equal(engine.turnRetryDelays(), TURN_RETRY_DELAYS_MS); }); -test('正常消息不会被误判成错误', () => { - for (const m of [ - '用了 400 tokens', - '第 404 步完成', - '剩余 422 积分', - '当前已达到 Token Plan 用量上限 (2067)', - '积分已耗尽', - '', - undefined, - ]) { - assert.equal(isTransientErrorMessage(m), false, `不该判为暂时性:${m}`); - } +for (const longRetry of [false, true]) { + test(`a failing turn runs once plus one retry per wait of the ${longRetry ? 'long' : 'default'} window, and shows n of max`, async (t) => { + const { store, engine, events } = await fixture(t, { retryDelaysMs: [1, 1], longRetryDelaysMs: [1, 1, 1, 1, 1] }); + store.state.settings.longRetry = longRetry; + const retries = longRetry ? 5 : 2; + const calls = { count: 0 }; + engine.agent = alwaysFailing(calls); + const bot = store.state.bots[0]; + const chatId = `bot:${bot.id}`; + const result = await engine.runBotTurn({ bot, chatId, text: 'Hello', kind: 'private' }); + assert.equal(result.status, 'error'); + assert.equal(calls.count, retries + 1); + assert.equal((await store.getMessages(chatId)).at(-1).retried, retries); + const shown = events + .map((event) => event.message?.live) + .filter((live) => live?.key === 'live.retrying') + .map((live) => `${live.params.n}/${live.params.max}`); + assert.deepEqual([...new Set(shown)], Array.from({ length: retries }, (_, i) => `${i + 2}/${retries + 1}`)); + }); +} + +test('an engine given no retry waits never retries, even with the long window on', async (t) => { + const { store, engine } = await fixture(t, { retryDelaysMs: [], longRetryDelaysMs: [] }); + store.state.settings.longRetry = true; + const calls = { count: 0 }; + engine.agent = alwaysFailing(calls); + const bot = store.state.bots[0]; + await engine.runBotTurn({ bot, chatId: `bot:${bot.id}`, text: 'Hello', kind: 'private' }); + assert.equal(calls.count, 1); }); diff --git a/plugins/minimax-ai/minimax-bot/tests/turn-retry-veto.test.mjs b/plugins/minimax-ai/minimax-bot/tests/turn-retry-veto.test.mjs new file mode 100644 index 00000000..27a57be5 --- /dev/null +++ b/plugins/minimax-ai/minimax-bot/tests/turn-retry-veto.test.mjs @@ -0,0 +1,118 @@ +// Engine-level checks of when a failed turn is retried automatically. Every case drives the real +// `Engine#runBotTurn` with a fake Agent and counts how many times the Agent was invoked. +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Store } from '../miniapp/node/store.mjs'; +import { MemoryStore } from '../miniapp/node/memory.mjs'; +import { Engine } from '../miniapp/node/engine.mjs'; +import { setLocale } from '../miniapp/node/i18n.mjs'; + +/** Three short retries: a turn that is retried every time runs 4 times. */ +const SHORT_DELAYS = [1, 1, 1]; + +async function fixture(t, run) { + const dir = await mkdtemp(join(tmpdir(), 'bot-turn-retry-')); + const store = new Store(dir); + const memory = new MemoryStore(dir); + await store.init(); + await memory.init(); + setLocale('en'); + const engine = new Engine({ store, memory, logger: { warn() {} }, broadcast() {}, retryDelaysMs: SHORT_DELAYS }); + const calls = { count: 0 }; + engine.agent = { + async run(input, options) { + calls.count += 1; + return run(calls.count, input, options); + }, + }; + t.after(async () => { + engine.dispose(); + await store.writeChain; + await rm(dir, { recursive: true, force: true }); + }); + const bot = store.state.bots[0]; + const chatId = `bot:${bot.id}`; + const turn = () => engine.runBotTurn({ bot, chatId, text: 'Hello', kind: 'private' }); + return { store, engine, calls, turn, chatId }; +} + +function agentError(fields) { + return Object.assign(new Error(fields.message), fields); +} + +test('a thrown AGENT_OUTCOME_UNKNOWN with retryable:false is not retried even if its text names a timeout', async (t) => { + const { calls, turn } = await fixture(t, () => { + throw agentError({ code: 'AGENT_OUTCOME_UNKNOWN', retryable: false, message: 'connect ETIMEDOUT 10.0.0.1:443' }); + }); + const result = await turn(); + assert.equal(calls.count, 1); + assert.equal(result.status, 'error'); +}); + +test('a thrown error with retryable:false is not retried even if its text says "rate limit"', async (t) => { + const { calls, turn } = await fixture(t, () => { + throw agentError({ code: 'AGENT_UNAVAILABLE', retryable: false, message: 'rate limit: Token Plan quota' }); + }); + await turn(); + assert.equal(calls.count, 1); +}); + +test('a thrown AGENT_OUTCOME_UNKNOWN is never retried, even when it claims to be retryable', async (t) => { + const { calls, turn } = await fixture(t, () => { + throw agentError({ code: 'AGENT_OUTCOME_UNKNOWN', retryable: true, message: 'Abort not confirmed' }); + }); + await turn(); + assert.equal(calls.count, 1); +}); + +test('an error result with retryable:false is not retried even if its text names a 5xx', async (t) => { + const { calls, turn } = await fixture(t, (n, input, options) => { + options.onEvent({ type: 'turn.started', sessionId: 's1', turnId: `t${n}` }); + return { sessionId: 's1', turnId: `t${n}`, status: 'error', error: { code: 'LLM_UPSTREAM_ERROR', message: 'Internal server error (500)', retryable: false } }; + }); + await turn(); + assert.equal(calls.count, 1); +}); + +test('an error result coded AGENT_OUTCOME_UNKNOWN is not retried', async (t) => { + const { calls, turn } = await fixture(t, (n, input, options) => { + options.onEvent({ type: 'turn.started', sessionId: 's1', turnId: `t${n}` }); + return { sessionId: 's1', turnId: `t${n}`, status: 'error', error: { code: 'AGENT_OUTCOME_UNKNOWN', message: 'Outcome unknown' } }; + }); + await turn(); + assert.equal(calls.count, 1); +}); + +test('a thrown retryable error is retried, and the retry stops at the first success', async (t) => { + const { calls, turn, store, chatId } = await fixture(t, (n, input, options) => { + if (n < 3) throw agentError({ code: 'AGENT_UNAVAILABLE', retryable: true, message: 'Unavailable' }); + options.onEvent({ type: 'turn.started', sessionId: 's1', turnId: `t${n}` }); + return { sessionId: 's1', turnId: `t${n}`, status: 'finished', text: 'Done' }; + }); + const result = await turn(); + assert.equal(calls.count, 3); + assert.equal(result.status, 'done'); + assert.equal((await store.getMessages(chatId)).at(-1).text, 'Done'); +}); + +test('a failed result with no retry verdict is retried until the window runs out', async (t) => { + const { calls, turn, store, chatId } = await fixture(t, (n, input, options) => { + options.onEvent({ type: 'turn.started', sessionId: 's1', turnId: `t${n}` }); + return { sessionId: 's1', turnId: `t${n}`, status: 'error', error: { code: 'TURN_FAILED', message: 'The Agent turn failed.' } }; + }); + await turn(); + assert.equal(calls.count, SHORT_DELAYS.length + 1); + assert.equal((await store.getMessages(chatId)).at(-1).retried, SHORT_DELAYS.length); +}); + +test('a failed result whose reason a retry cannot fix is not retried', async (t) => { + const { calls, turn } = await fixture(t, (n, input, options) => { + options.onEvent({ type: 'turn.started', sessionId: 's1', turnId: `t${n}` }); + return { sessionId: 's1', turnId: `t${n}`, status: 'error', error: { code: 'LLM_QUOTA', message: 'rate limit: quota used up', reason: 'quota' } }; + }); + await turn(); + assert.equal(calls.count, 1); +}); From 71848a3a6753c843e49533ab96609d1bed8ca720 Mon Sep 17 00:00:00 2001 From: Hylouis233 Date: Thu, 22 Oct 2026 19:30:06 +0800 Subject: [PATCH 4/4] fix(minimax-bot): drop the optional long retry window, keep the two default retries Follow the simplified plan from the 2026-10-22 review: no `longRetry` setting and no 10-step window. The default stays at two retries (8 s, 20 s). - `mayRetryFailure` holds the retry rule in one place: an explicit `retryable: false` always vetoes, on the thrown and the returned path; `AGENT_OUTCOME_UNKNOWN` is never resent; `NO_RETRY_REASONS` now also applies to a thrown error. - Export `TURN_RETRY_DELAYS_MS`, `NO_RETRY_REASONS` and `mayRetryFailure` and assert the real values; the engine-level loop tests stay in turn-retry-veto.test.mjs. - README 0.7.8 section describes only the plugin's own behaviour. --- plugins/minimax-ai/minimax-bot/README.md | 9 +- .../minimax-bot/miniapp/node/engine.mjs | 73 +++++++------- .../minimax-bot/miniapp/node/server.mjs | 2 - .../minimax-bot/miniapp/node/store.mjs | 4 +- .../tests/turn-retry-backoff.test.mjs | 99 +++++-------------- 5 files changed, 66 insertions(+), 121 deletions(-) diff --git a/plugins/minimax-ai/minimax-bot/README.md b/plugins/minimax-ai/minimax-bot/README.md index d326c672..31261d39 100644 --- a/plugins/minimax-ai/minimax-bot/README.md +++ b/plugins/minimax-ai/minimax-bot/README.md @@ -30,10 +30,11 @@ Copy this directory, including the hidden `.minimax-plugin/` directory, into the - **Reply actions**: the reaction, copy, quote-reply and open-session buttons sit at the bottom-left of a reply, under its last line. A long reply therefore keeps its actions within reach at the end of the text instead of pushing them off the top of the message. The reaction indicator sits directly above that row and is painted with the app's own tag colours, so it follows the light and dark themes. - **Working messages of one reply**: the Host records a text part for every turn a Bot takes between two tool calls, so a reply that worked for a while arrives with many parts, and the page draws one bubble per part. The bubbles keep the official shape and order, and a small gap between them tells them apart instead of letting them read as one block of text. -## 0.7.8 An optional longer retry window, and stricter retry rules +## 0.7.8 Safer automatic retries -- **Longer retry window, off by default.** A failed turn is still retried twice (after 8 s and 20 s). Turning on `longRetry` in the settings (`PUT /api/settings` with `{"longRetry": true}`) retries it up to ten times instead, backing off 8 s, 20 s, 40 s and then 60 s, about 8 minutes in all. After the last retry the original error is shown. -- **A turn whose outcome is unknown is never retried.** `AGENT_OUTCOME_UNKNOWN` means the turn may already have run, so sending it again could repeat its reply or its actions. Otherwise what is retried is unchanged: only a turn that failed before running any tool, and never a failure explicitly marked as not retryable, whether it was thrown or returned. +- A failed turn is still retried automatically at most twice, after 8 s and 20 s. +- A failure marked `retryable: false` is never retried automatically, whether it was thrown or returned, and whatever its message says. +- A turn whose outcome is unknown (`AGENT_OUTCOME_UNKNOWN`) is never resent automatically: it may already have run, and resending it could repeat a reply or an action. ## 0.7.0 Reliable delegation and result delivery @@ -70,7 +71,7 @@ Avatars are stored in the Mini App's `dataDir/avatars/`. `GET /api/avatars` list - Full history is kept locally. The page loads the latest 200 messages first and can page back through older ones; Copy as Markdown covers the whole history. - Messages carry stable increasing sequence numbers. A group member only advances past the snapshot that was actually submitted to the host, so messages arriving during its reply are not skipped. - Sending is de-duplicated per chat, and retries of the same content and attachments reuse the same submission ID. After a crash, only requests that clearly never started are recovered; when a reply placeholder exists but the host outcome is unknown, the request is reported as `delivery_uncertain` and the draft is kept, so tasks with side effects are not run twice. -- Failed turns are retried automatically (after 8 s and 20 s, or for about 8 minutes with the `longRetry` setting), and a retry button is shown afterwards; if a message never reached the Bot, retry resends it. +- Failed turns are retried automatically (after 8 s and 20 s), and a retry button is shown afterwards; if a message never reached the Bot, retry resends it. - Memory writes use a recoverable transaction covering the entry and its journal; an interrupted write returns `memory_pending` and is completed by a read, retry or restart without double counting. Before the first change to an existing scope, the original file is backed up to `dataDir/memory/backups/`. ## 0.6.5 Execution records and outcome notices diff --git a/plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs b/plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs index f5a05dfc..d74983e0 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs +++ b/plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs @@ -30,23 +30,30 @@ const MAX_HELPERS_PER_BOT = 4; const HELPER_REPORT_CLIP = 8000; const HELPER_ACTIVE = new Set(['queued', 'running']); /** - * A turn that failed before running any tool is retried this many more times, after these waits - * (2 retries, about 28 seconds in all). Model capacity errors (429/529) and timeouts usually - * clear within a minute. + * A turn that failed before running any tool is retried this many more times, after these waits. + * Model capacity errors (429/529) and timeouts usually clear within a minute. */ export const TURN_RETRY_DELAYS_MS = Object.freeze([8000, 20000]); -/** - * The longer retry window, used only when the user turns on `settings.longRetry`: 10 retries - * backing off 8 s, 20 s, 40 s, then 60 s seven times, 488 seconds (about 8 minutes) in all. - * It retries the same failures as the default window, only for longer; after the last retry the - * original error is shown. - */ -export const LONG_TURN_RETRY_DELAYS_MS = Object.freeze([8000, 20000, 40000, 60000, 60000, 60000, 60000, 60000, 60000, 60000]); /** Failure reasons a retry cannot fix; anything else (or no reason from an older Host) may be retried. */ -const NO_RETRY_REASONS = new Set(['auth', 'quota', 'context_too_long', 'blocked', 'invalid_request']); -/** The turn may already have run, so sending it again could repeat its reply or its actions. */ +export const NO_RETRY_REASONS = new Set(['auth', 'quota', 'context_too_long', 'blocked', 'invalid_request']); +/** The turn may already have run, so resending it could repeat a reply or an action. */ const OUTCOME_UNKNOWN = 'AGENT_OUTCOME_UNKNOWN'; +/** + * Whether a failed turn may be sent again automatically. `retryable: false` always vetoes, + * whether it was thrown or returned, and so does an unknown outcome or a reason a retry + * cannot fix. A thrown error is retried only when it says `retryable: true`; a returned + * `error` result without the flag may be retried. + * + * @param {{ thrown: boolean, retryable?: unknown, code?: string, reason?: string }} failure + */ +export function mayRetryFailure({ thrown, retryable, code, reason }) { + if (retryable === false) return false; + if (code === OUTCOME_UNKNOWN) return false; + if (reason && NO_RETRY_REASONS.has(reason)) return false; + return thrown ? retryable === true : true; +} + /** Resolves true after `ms`, or false at once if `signal` aborts first. */ function sleepUnlessAborted(ms, signal) { return new Promise((resolve) => { @@ -131,8 +138,6 @@ export class Engine { * agent: MiniAppAgentClient | undefined, * logger: import('./miniapp-api.js').MiniAppLogger, * broadcast: (event: Record) => void, - * retryDelaysMs?: readonly number[], - * longRetryDelaysMs?: readonly number[], * }} deps */ constructor(deps) { @@ -142,8 +147,6 @@ export class Engine { this.logger = deps.logger; /** Waits before each automatic retry of a failed turn (tests shorten them). */ this.retryDelays = deps.retryDelaysMs ?? TURN_RETRY_DELAYS_MS; - /** Waits used instead when `settings.longRetry` is on (tests shorten them). */ - this.longRetryDelays = deps.longRetryDelaysMs ?? LONG_TURN_RETRY_DELAYS_MS; /** @type {{ at: number, value: Promise } | undefined} */ this.catalogCache = undefined; /** @type {Map} helper id -> run */ @@ -323,11 +326,6 @@ export class Engine { return `${this.buildStandingBrief(bot, chatId)}\n\n${await this.buildMemoryBrief(bot, query)}`; } - /** The waits before each automatic retry of a failed turn: the long window only when the user turned it on. */ - turnRetryDelays() { - return this.store.state.settings?.longRetry === true ? this.longRetryDelays : this.retryDelays; - } - // ------------------------------------------------------------------ core turn /** * Run one turn for a Bot in a chat and stream it into a new Bot message. @@ -523,16 +521,17 @@ export class Engine { let result; let failure = ''; let failureCode = ''; - let failureRetryable = false; + /** @type {unknown} */ + let failureRetryable; + let failureReason = ''; let attempt = 0; - // Fixed for the whole turn, so the cap and the "n of max" shown while retrying agree. - const retryDelays = this.turnRetryDelays(); try { for (;;) { result = undefined; failure = ''; failureCode = ''; - failureRetryable = false; + failureRetryable = undefined; + failureReason = ''; if (controller.signal.aborted || this.isStale(ticket)) { controller.abort(); result = skippedTurn(); @@ -556,37 +555,35 @@ export class Engine { } catch (retryError) { failure = describeAgentError(retryError); failureCode = errorCode(retryError); - failureRetryable = /** @type {any} */ (retryError)?.retryable === true; + failureRetryable = /** @type {any} */ (retryError)?.retryable; + failureReason = String(/** @type {any} */ (retryError)?.reason ?? ''); } } else { failure = describeAgentError(error); failureCode = errorCode(error); - failureRetryable = /** @type {any} */ (error)?.retryable === true; + failureRetryable = /** @type {any} */ (error)?.retryable; + failureReason = String(/** @type {any} */ (error)?.reason ?? ''); } } - // A thrown error is retried only when it says it is retryable; a failed result is retried - // unless it says it is not. Either way an explicit `retryable: false`, an outcome that may - // already have run, or a reason a retry cannot fix ends the turn. const thrown = Boolean(failure); - const reason = result?.error?.reason; - const failed = thrown ? failureRetryable : result?.status === 'error' && result?.error?.retryable !== false; - const outcomeUnknown = failureCode === OUTCOME_UNKNOWN || result?.error?.code === OUTCOME_UNKNOWN; + const failed = thrown || result?.status === 'error'; const canRetry = failed && - !outcomeUnknown && - !NO_RETRY_REASONS.has(reason) && + mayRetryFailure(thrown + ? { thrown, retryable: failureRetryable, code: failureCode, reason: failureReason } + : { thrown, retryable: result?.error?.retryable, code: result?.error?.code, reason: result?.error?.reason }) && !input.answer && !toolStarted && - attempt < retryDelays.length && + attempt < this.retryDelays.length && !controller.signal.aborted && !this.disposed; if (!canRetry) break; - const delay = retryDelays[attempt]; + const delay = this.retryDelays[attempt]; attempt += 1; // Words the failed attempt streamed are not the reply; the retry writes its own. message.parts = message.parts.filter((part) => part.type !== 'text'); liveText = undefined; - message.live = { key: 'live.retrying', params: { s: Math.round(delay / 1000), n: attempt + 1, max: retryDelays.length + 1 } }; + message.live = { key: 'live.retrying', params: { s: Math.round(delay / 1000), n: attempt + 1, max: this.retryDelays.length + 1 } }; flush(true); const waited = await sleepUnlessAborted(delay, controller.signal); message.live = undefined; diff --git a/plugins/minimax-ai/minimax-bot/miniapp/node/server.mjs b/plugins/minimax-ai/minimax-bot/miniapp/node/server.mjs index 8e6dc70d..9e27e240 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/node/server.mjs +++ b/plugins/minimax-ai/minimax-bot/miniapp/node/server.mjs @@ -516,8 +516,6 @@ export async function start(context) { if (name.length > USER_NAME_MAX) throw new EngineError('invalid', t('err.userNameTooLong', { max: USER_NAME_MAX })); next.userName = name; } - // Off by default: retry a failed turn for about 8 minutes instead of about 28 seconds. - if (body.longRetry !== undefined) next.longRetry = body.longRetry === true; store.state.settings = next; if (body.language !== undefined) await team.applyLocale(); else { diff --git a/plugins/minimax-ai/minimax-bot/miniapp/node/store.mjs b/plugins/minimax-ai/minimax-bot/miniapp/node/store.mjs index 77b38590..6dc837ed 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/node/store.mjs +++ b/plugins/minimax-ai/minimax-bot/miniapp/node/store.mjs @@ -151,8 +151,8 @@ function defaultState() { /** Background helpers: queued / running ones and the most recent finished ones. */ helpers: [], taskBatches: [], - /** `language`: 'auto' (follow the OS) | 'zh' | 'en'. `longRetry`: retry failed turns for about 8 minutes instead of about 28 seconds. */ - settings: { language: 'auto', userName: '', longRetry: false }, + /** `language`: 'auto' (follow the OS) | 'zh' | 'en' */ + settings: { language: 'auto', userName: '' }, /** OS language last reported by the page, used to resolve `auto`. */ systemLocale: undefined, }; diff --git a/plugins/minimax-ai/minimax-bot/tests/turn-retry-backoff.test.mjs b/plugins/minimax-ai/minimax-bot/tests/turn-retry-backoff.test.mjs index 4f85581c..163b766c 100644 --- a/plugins/minimax-ai/minimax-bot/tests/turn-retry-backoff.test.mjs +++ b/plugins/minimax-ai/minimax-bot/tests/turn-retry-backoff.test.mjs @@ -1,5 +1,5 @@ -// The automatic retry windows of a failed turn: the real exported defaults, and how the engine -// picks and applies them. +// The automatic retry of a failed turn: the real exported default window and retry rule. +// The retry loop itself is driven end to end in turn-retry-veto.test.mjs. import assert from 'node:assert/strict'; import { test } from 'node:test'; import { mkdtemp, rm } from 'node:fs/promises'; @@ -7,83 +7,32 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Store } from '../miniapp/node/store.mjs'; import { MemoryStore } from '../miniapp/node/memory.mjs'; -import { Engine, LONG_TURN_RETRY_DELAYS_MS, TURN_RETRY_DELAYS_MS } from '../miniapp/node/engine.mjs'; -import { setLocale } from '../miniapp/node/i18n.mjs'; +import { Engine, NO_RETRY_REASONS, TURN_RETRY_DELAYS_MS, mayRetryFailure } from '../miniapp/node/engine.mjs'; -async function fixture(t, deps = {}) { - const dir = await mkdtemp(join(tmpdir(), 'bot-turn-retry-window-')); - const store = new Store(dir); - const memory = new MemoryStore(dir); - await store.init(); - await memory.init(); - setLocale('en'); - const events = []; - const engine = new Engine({ store, memory, logger: { warn() {} }, broadcast: (event) => events.push(structuredClone(event)), ...deps }); - t.after(async () => { - engine.dispose(); - await store.writeChain; - await rm(dir, { recursive: true, force: true }); - }); - return { store, engine, events }; -} - -/** An Agent whose every turn starts and then fails with no retry verdict. */ -function alwaysFailing(calls) { - return { - async run(input, options) { - calls.count += 1; - options.onEvent({ type: 'turn.started', sessionId: 's1', turnId: `t${calls.count}` }); - return { sessionId: 's1', turnId: `t${calls.count}`, status: 'error', error: { code: 'TURN_FAILED', message: 'The Agent turn failed.' } }; - }, - }; -} - -test('the default window is two retries after 8 s and 20 s, and a new engine uses it', async (t) => { - assert.deepEqual(TURN_RETRY_DELAYS_MS, [8000, 20000]); +test('the default window is two retries, after 8 s and 20 s, and the engine uses it', async (t) => { + assert.deepEqual([...TURN_RETRY_DELAYS_MS], [8000, 20000]); assert.ok(Object.isFrozen(TURN_RETRY_DELAYS_MS)); - const { store, engine } = await fixture(t); - assert.equal(store.state.settings.longRetry, false); - assert.equal(engine.turnRetryDelays(), TURN_RETRY_DELAYS_MS); + const dir = await mkdtemp(join(tmpdir(), 'bot-turn-retry-default-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const engine = new Engine({ store: new Store(dir), memory: new MemoryStore(dir), logger: { warn() {} }, broadcast() {} }); + assert.equal(engine.retryDelays, TURN_RETRY_DELAYS_MS); + engine.dispose(); }); -test('the long window is ten capped retries, 488 seconds in all, used only when the setting is on', async (t) => { - assert.deepEqual(LONG_TURN_RETRY_DELAYS_MS, [8000, 20000, 40000, 60000, 60000, 60000, 60000, 60000, 60000, 60000]); - assert.ok(Object.isFrozen(LONG_TURN_RETRY_DELAYS_MS)); - assert.equal(LONG_TURN_RETRY_DELAYS_MS.reduce((sum, ms) => sum + ms, 0), 488_000); - const { store, engine } = await fixture(t); - store.state.settings.longRetry = true; - assert.equal(engine.turnRetryDelays(), LONG_TURN_RETRY_DELAYS_MS); - store.state.settings.longRetry = false; - assert.equal(engine.turnRetryDelays(), TURN_RETRY_DELAYS_MS); +test('mayRetryFailure: retryable:false, AGENT_OUTCOME_UNKNOWN and unfixable reasons always veto', () => { + for (const thrown of [true, false]) { + assert.equal(mayRetryFailure({ thrown, retryable: false }), false); + assert.equal(mayRetryFailure({ thrown, retryable: true, code: 'AGENT_OUTCOME_UNKNOWN' }), false); + assert.equal(mayRetryFailure({ thrown, code: 'AGENT_OUTCOME_UNKNOWN' }), false); + for (const reason of NO_RETRY_REASONS) { + assert.equal(mayRetryFailure({ thrown, retryable: true, reason }), false, reason); + } + } }); -for (const longRetry of [false, true]) { - test(`a failing turn runs once plus one retry per wait of the ${longRetry ? 'long' : 'default'} window, and shows n of max`, async (t) => { - const { store, engine, events } = await fixture(t, { retryDelaysMs: [1, 1], longRetryDelaysMs: [1, 1, 1, 1, 1] }); - store.state.settings.longRetry = longRetry; - const retries = longRetry ? 5 : 2; - const calls = { count: 0 }; - engine.agent = alwaysFailing(calls); - const bot = store.state.bots[0]; - const chatId = `bot:${bot.id}`; - const result = await engine.runBotTurn({ bot, chatId, text: 'Hello', kind: 'private' }); - assert.equal(result.status, 'error'); - assert.equal(calls.count, retries + 1); - assert.equal((await store.getMessages(chatId)).at(-1).retried, retries); - const shown = events - .map((event) => event.message?.live) - .filter((live) => live?.key === 'live.retrying') - .map((live) => `${live.params.n}/${live.params.max}`); - assert.deepEqual([...new Set(shown)], Array.from({ length: retries }, (_, i) => `${i + 2}/${retries + 1}`)); - }); -} - -test('an engine given no retry waits never retries, even with the long window on', async (t) => { - const { store, engine } = await fixture(t, { retryDelaysMs: [], longRetryDelaysMs: [] }); - store.state.settings.longRetry = true; - const calls = { count: 0 }; - engine.agent = alwaysFailing(calls); - const bot = store.state.bots[0]; - await engine.runBotTurn({ bot, chatId: `bot:${bot.id}`, text: 'Hello', kind: 'private' }); - assert.equal(calls.count, 1); +test('mayRetryFailure: a thrown error needs retryable:true; a returned error result needs no flag', () => { + assert.equal(mayRetryFailure({ thrown: true }), false); + assert.equal(mayRetryFailure({ thrown: true, retryable: true, code: 'AGENT_UNAVAILABLE' }), true); + assert.equal(mayRetryFailure({ thrown: false, code: 'TURN_FAILED' }), true); + assert.equal(mayRetryFailure({ thrown: false, retryable: true }), true); });