Skip to content

Commit f524e30

Browse files
authored
fix(minimax-bot): 回合重试从 2 次延长到 10 档(仍有上限),400/404/422 不重试 (#49)
* fix(minimax-bot): 回合重试从 2 次延长到 10 档(仍有上限),400/404/422 不重试 ## 要解决的问题:一次 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`;文本匹配只是补位, 不是另起一套分类。 * fix(minimax-bot): 修 max 显示成 2/NaN、清掉过期的「无限重试」注释、版本号三处对齐 0.7.5 维护者 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 个是上游自己的失败)。 * 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. * 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. --------- Co-authored-by: Hylouis233 <Hylouis233@users.noreply.github.com>
1 parent 4e1856a commit f524e30

7 files changed

Lines changed: 199 additions & 14 deletions

File tree

‎plugins/minimax-ai/minimax-bot/.minimax-plugin/plugin.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"schemaVersion": 1,
33
"name": "minimax-bot",
44
"displayName": "MiniMax Bot",
5-
"version": "0.7.7",
5+
"version": "0.7.8",
66
"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.",
77
"author": "MiniMax Code",
88
"icon": "icon.png",

‎plugins/minimax-ai/minimax-bot/README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ Copy this directory, including the hidden `.minimax-plugin/` directory, into the
3030
- **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.
3131
- **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.
3232

33+
## 0.7.8 Safer automatic retries
34+
35+
- A failed turn is still retried automatically at most twice, after 8 s and 20 s.
36+
- A failure marked `retryable: false` is never retried automatically, whether it was thrown or returned, and whatever its message says.
37+
- 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.
38+
3339
## 0.7.0 Reliable delegation and result delivery
3440

3541
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.

‎plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs‎

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,26 @@ const HELPER_ACTIVE = new Set(['queued', 'running']);
3333
* A turn that failed before running any tool is retried this many more times, after these waits.
3434
* Model capacity errors (429/529) and timeouts usually clear within a minute.
3535
*/
36-
const TURN_RETRY_DELAYS_MS = [8000, 20000];
36+
export const TURN_RETRY_DELAYS_MS = Object.freeze([8000, 20000]);
3737
/** Failure reasons a retry cannot fix; anything else (or no reason from an older Host) may be retried. */
38-
const NO_RETRY_REASONS = new Set(['auth', 'quota', 'context_too_long', 'blocked', 'invalid_request']);
38+
export const NO_RETRY_REASONS = new Set(['auth', 'quota', 'context_too_long', 'blocked', 'invalid_request']);
39+
/** The turn may already have run, so resending it could repeat a reply or an action. */
40+
const OUTCOME_UNKNOWN = 'AGENT_OUTCOME_UNKNOWN';
41+
42+
/**
43+
* Whether a failed turn may be sent again automatically. `retryable: false` always vetoes,
44+
* whether it was thrown or returned, and so does an unknown outcome or a reason a retry
45+
* cannot fix. A thrown error is retried only when it says `retryable: true`; a returned
46+
* `error` result without the flag may be retried.
47+
*
48+
* @param {{ thrown: boolean, retryable?: unknown, code?: string, reason?: string }} failure
49+
*/
50+
export function mayRetryFailure({ thrown, retryable, code, reason }) {
51+
if (retryable === false) return false;
52+
if (code === OUTCOME_UNKNOWN) return false;
53+
if (reason && NO_RETRY_REASONS.has(reason)) return false;
54+
return thrown ? retryable === true : true;
55+
}
3956

4057
/** Resolves true after `ms`, or false at once if `signal` aborts first. */
4158
function sleepUnlessAborted(ms, signal) {
@@ -504,14 +521,17 @@ export class Engine {
504521
let result;
505522
let failure = '';
506523
let failureCode = '';
507-
let failureRetryable = false;
524+
/** @type {unknown} */
525+
let failureRetryable;
526+
let failureReason = '';
508527
let attempt = 0;
509528
try {
510529
for (;;) {
511530
result = undefined;
512531
failure = '';
513532
failureCode = '';
514-
failureRetryable = false;
533+
failureRetryable = undefined;
534+
failureReason = '';
515535
if (controller.signal.aborted || this.isStale(ticket)) {
516536
controller.abort();
517537
result = skippedTurn();
@@ -535,25 +555,28 @@ export class Engine {
535555
} catch (retryError) {
536556
failure = describeAgentError(retryError);
537557
failureCode = errorCode(retryError);
538-
failureRetryable = Boolean(/** @type {any} */ (retryError)?.retryable);
558+
failureRetryable = /** @type {any} */ (retryError)?.retryable;
559+
failureReason = String(/** @type {any} */ (retryError)?.reason ?? '');
539560
}
540561
} else {
541562
failure = describeAgentError(error);
542563
failureCode = errorCode(error);
543-
failureRetryable = Boolean(/** @type {any} */ (error)?.retryable);
564+
failureRetryable = /** @type {any} */ (error)?.retryable;
565+
failureReason = String(/** @type {any} */ (error)?.reason ?? '');
544566
}
545567
}
546-
const failed = Boolean(failure) ? failureRetryable : result?.status === 'error';
547-
const reason = result?.error?.reason;
568+
const thrown = Boolean(failure);
569+
const failed = thrown || result?.status === 'error';
548570
const canRetry =
549571
failed &&
572+
mayRetryFailure(thrown
573+
? { thrown, retryable: failureRetryable, code: failureCode, reason: failureReason }
574+
: { thrown, retryable: result?.error?.retryable, code: result?.error?.code, reason: result?.error?.reason }) &&
550575
!input.answer &&
551576
!toolStarted &&
552577
attempt < this.retryDelays.length &&
553578
!controller.signal.aborted &&
554-
!this.disposed &&
555-
result?.error?.retryable !== false &&
556-
!NO_RETRY_REASONS.has(reason);
579+
!this.disposed;
557580
if (!canRetry) break;
558581
const delay = this.retryDelays[attempt];
559582
attempt += 1;

‎plugins/minimax-ai/minimax-bot/miniapp/node/mcp.mjs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -586,7 +586,7 @@ export function createMcpHandler({ team, engine }) {
586586
result = {
587587
protocolVersion: PROTOCOL_VERSIONS.includes(requested) ? requested : PROTOCOL_VERSIONS[0],
588588
capabilities: { tools: { listChanged: false } },
589-
serverInfo: { name: 'minimax-bot', version: '0.7.7' },
589+
serverInfo: { name: 'minimax-bot', version: '0.7.8' },
590590
instructions: t('mcp.instructions'),
591591
};
592592
break;

‎plugins/minimax-ai/minimax-bot/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "minimax-bot",
3-
"version": "0.7.7",
3+
"version": "0.7.8",
44
"private": true,
55
"type": "module",
66
"mcode": {
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// The automatic retry of a failed turn: the real exported default window and retry rule.
2+
// The retry loop itself is driven end to end in turn-retry-veto.test.mjs.
3+
import assert from 'node:assert/strict';
4+
import { test } from 'node:test';
5+
import { mkdtemp, rm } from 'node:fs/promises';
6+
import { tmpdir } from 'node:os';
7+
import { join } from 'node:path';
8+
import { Store } from '../miniapp/node/store.mjs';
9+
import { MemoryStore } from '../miniapp/node/memory.mjs';
10+
import { Engine, NO_RETRY_REASONS, TURN_RETRY_DELAYS_MS, mayRetryFailure } from '../miniapp/node/engine.mjs';
11+
12+
test('the default window is two retries, after 8 s and 20 s, and the engine uses it', async (t) => {
13+
assert.deepEqual([...TURN_RETRY_DELAYS_MS], [8000, 20000]);
14+
assert.ok(Object.isFrozen(TURN_RETRY_DELAYS_MS));
15+
const dir = await mkdtemp(join(tmpdir(), 'bot-turn-retry-default-'));
16+
t.after(() => rm(dir, { recursive: true, force: true }));
17+
const engine = new Engine({ store: new Store(dir), memory: new MemoryStore(dir), logger: { warn() {} }, broadcast() {} });
18+
assert.equal(engine.retryDelays, TURN_RETRY_DELAYS_MS);
19+
engine.dispose();
20+
});
21+
22+
test('mayRetryFailure: retryable:false, AGENT_OUTCOME_UNKNOWN and unfixable reasons always veto', () => {
23+
for (const thrown of [true, false]) {
24+
assert.equal(mayRetryFailure({ thrown, retryable: false }), false);
25+
assert.equal(mayRetryFailure({ thrown, retryable: true, code: 'AGENT_OUTCOME_UNKNOWN' }), false);
26+
assert.equal(mayRetryFailure({ thrown, code: 'AGENT_OUTCOME_UNKNOWN' }), false);
27+
for (const reason of NO_RETRY_REASONS) {
28+
assert.equal(mayRetryFailure({ thrown, retryable: true, reason }), false, reason);
29+
}
30+
}
31+
});
32+
33+
test('mayRetryFailure: a thrown error needs retryable:true; a returned error result needs no flag', () => {
34+
assert.equal(mayRetryFailure({ thrown: true }), false);
35+
assert.equal(mayRetryFailure({ thrown: true, retryable: true, code: 'AGENT_UNAVAILABLE' }), true);
36+
assert.equal(mayRetryFailure({ thrown: false, code: 'TURN_FAILED' }), true);
37+
assert.equal(mayRetryFailure({ thrown: false, retryable: true }), true);
38+
});
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
// Engine-level checks of when a failed turn is retried automatically. Every case drives the real
2+
// `Engine#runBotTurn` with a fake Agent and counts how many times the Agent was invoked.
3+
import assert from 'node:assert/strict';
4+
import { test } from 'node:test';
5+
import { mkdtemp, rm } from 'node:fs/promises';
6+
import { tmpdir } from 'node:os';
7+
import { join } from 'node:path';
8+
import { Store } from '../miniapp/node/store.mjs';
9+
import { MemoryStore } from '../miniapp/node/memory.mjs';
10+
import { Engine } from '../miniapp/node/engine.mjs';
11+
import { setLocale } from '../miniapp/node/i18n.mjs';
12+
13+
/** Three short retries: a turn that is retried every time runs 4 times. */
14+
const SHORT_DELAYS = [1, 1, 1];
15+
16+
async function fixture(t, run) {
17+
const dir = await mkdtemp(join(tmpdir(), 'bot-turn-retry-'));
18+
const store = new Store(dir);
19+
const memory = new MemoryStore(dir);
20+
await store.init();
21+
await memory.init();
22+
setLocale('en');
23+
const engine = new Engine({ store, memory, logger: { warn() {} }, broadcast() {}, retryDelaysMs: SHORT_DELAYS });
24+
const calls = { count: 0 };
25+
engine.agent = {
26+
async run(input, options) {
27+
calls.count += 1;
28+
return run(calls.count, input, options);
29+
},
30+
};
31+
t.after(async () => {
32+
engine.dispose();
33+
await store.writeChain;
34+
await rm(dir, { recursive: true, force: true });
35+
});
36+
const bot = store.state.bots[0];
37+
const chatId = `bot:${bot.id}`;
38+
const turn = () => engine.runBotTurn({ bot, chatId, text: 'Hello', kind: 'private' });
39+
return { store, engine, calls, turn, chatId };
40+
}
41+
42+
function agentError(fields) {
43+
return Object.assign(new Error(fields.message), fields);
44+
}
45+
46+
test('a thrown AGENT_OUTCOME_UNKNOWN with retryable:false is not retried even if its text names a timeout', async (t) => {
47+
const { calls, turn } = await fixture(t, () => {
48+
throw agentError({ code: 'AGENT_OUTCOME_UNKNOWN', retryable: false, message: 'connect ETIMEDOUT 10.0.0.1:443' });
49+
});
50+
const result = await turn();
51+
assert.equal(calls.count, 1);
52+
assert.equal(result.status, 'error');
53+
});
54+
55+
test('a thrown error with retryable:false is not retried even if its text says "rate limit"', async (t) => {
56+
const { calls, turn } = await fixture(t, () => {
57+
throw agentError({ code: 'AGENT_UNAVAILABLE', retryable: false, message: 'rate limit: Token Plan quota' });
58+
});
59+
await turn();
60+
assert.equal(calls.count, 1);
61+
});
62+
63+
test('a thrown AGENT_OUTCOME_UNKNOWN is never retried, even when it claims to be retryable', async (t) => {
64+
const { calls, turn } = await fixture(t, () => {
65+
throw agentError({ code: 'AGENT_OUTCOME_UNKNOWN', retryable: true, message: 'Abort not confirmed' });
66+
});
67+
await turn();
68+
assert.equal(calls.count, 1);
69+
});
70+
71+
test('an error result with retryable:false is not retried even if its text names a 5xx', async (t) => {
72+
const { calls, turn } = await fixture(t, (n, input, options) => {
73+
options.onEvent({ type: 'turn.started', sessionId: 's1', turnId: `t${n}` });
74+
return { sessionId: 's1', turnId: `t${n}`, status: 'error', error: { code: 'LLM_UPSTREAM_ERROR', message: 'Internal server error (500)', retryable: false } };
75+
});
76+
await turn();
77+
assert.equal(calls.count, 1);
78+
});
79+
80+
test('an error result coded AGENT_OUTCOME_UNKNOWN is not retried', async (t) => {
81+
const { calls, turn } = await fixture(t, (n, input, options) => {
82+
options.onEvent({ type: 'turn.started', sessionId: 's1', turnId: `t${n}` });
83+
return { sessionId: 's1', turnId: `t${n}`, status: 'error', error: { code: 'AGENT_OUTCOME_UNKNOWN', message: 'Outcome unknown' } };
84+
});
85+
await turn();
86+
assert.equal(calls.count, 1);
87+
});
88+
89+
test('a thrown retryable error is retried, and the retry stops at the first success', async (t) => {
90+
const { calls, turn, store, chatId } = await fixture(t, (n, input, options) => {
91+
if (n < 3) throw agentError({ code: 'AGENT_UNAVAILABLE', retryable: true, message: 'Unavailable' });
92+
options.onEvent({ type: 'turn.started', sessionId: 's1', turnId: `t${n}` });
93+
return { sessionId: 's1', turnId: `t${n}`, status: 'finished', text: 'Done' };
94+
});
95+
const result = await turn();
96+
assert.equal(calls.count, 3);
97+
assert.equal(result.status, 'done');
98+
assert.equal((await store.getMessages(chatId)).at(-1).text, 'Done');
99+
});
100+
101+
test('a failed result with no retry verdict is retried until the window runs out', async (t) => {
102+
const { calls, turn, store, chatId } = await fixture(t, (n, input, options) => {
103+
options.onEvent({ type: 'turn.started', sessionId: 's1', turnId: `t${n}` });
104+
return { sessionId: 's1', turnId: `t${n}`, status: 'error', error: { code: 'TURN_FAILED', message: 'The Agent turn failed.' } };
105+
});
106+
await turn();
107+
assert.equal(calls.count, SHORT_DELAYS.length + 1);
108+
assert.equal((await store.getMessages(chatId)).at(-1).retried, SHORT_DELAYS.length);
109+
});
110+
111+
test('a failed result whose reason a retry cannot fix is not retried', async (t) => {
112+
const { calls, turn } = await fixture(t, (n, input, options) => {
113+
options.onEvent({ type: 'turn.started', sessionId: 's1', turnId: `t${n}` });
114+
return { sessionId: 's1', turnId: `t${n}`, status: 'error', error: { code: 'LLM_QUOTA', message: 'rate limit: quota used up', reason: 'quota' } };
115+
});
116+
await turn();
117+
assert.equal(calls.count, 1);
118+
});

0 commit comments

Comments
 (0)