Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/minimax-ai/minimax-bot/.minimax-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"schemaVersion": 1,
"name": "minimax-bot",
"displayName": "MiniMax Bot",
"version": "0.7.4",
"version": "0.7.5",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

请 rebase 后在 main 当时的版本号上再升一号,与另外两处保持一致。

"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",
Expand Down
31 changes: 31 additions & 0 deletions plugins/minimax-ai/minimax-bot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
119 changes: 115 additions & 4 deletions plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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<any> } | undefined} */
this.catalogCache = undefined;
/** @type {Map<string, { controller: AbortController, stoppedBy?: string }>} helper id -> run */
Expand Down Expand Up @@ -505,6 +563,10 @@ export class Engine {
let failure = '';
let failureCode = '';
let failureRetryable = false;
// 原始错误消息(未翻译):暂时性错误的判定必须看**它**——
// `failure` 是 `describeAgentError()` 翻译后的文案(可能是「模型不可用」这类
// 泛化说法),里面已经没有 500 / 网络切换这些关键信息了。
let rawFailureMessage = '';
let attempt = 0;
try {
for (;;) {
Expand Down Expand Up @@ -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`)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(非阻塞)这里引用的 TURN_RETRY_MAX_ATTEMPTS 并不存在,上限现在就是 this.retryDelays.length,建议改一下注释。

// 它只用来决定**退避用多长**。
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 分钟窗口),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(非阻塞)「约 7.5 分钟」与文件头的「约 8 分钟」不一致;默认窗口合计 488 秒,建议统一为「约 8 分钟」(测试文件头注释里的「60s×6,约 7.5 分钟」同理,实际是 60s×7)。

// 注入空数组仍然表示「完全不重试」(既有语义一字未改)。
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.retryDelays.length + 1,
},
};
flush(true);
const waited = await sleepUnlessAborted(delay, controller.signal);
message.live = undefined;
Expand Down
2 changes: 1 addition & 1 deletion plugins/minimax-ai/minimax-bot/miniapp/node/mcp.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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' },

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

请 rebase 后在 main 当时的版本号上再升一号,与另外两处保持一致。

instructions: t('mcp.instructions'),
};
break;
Expand Down
2 changes: 1 addition & 1 deletion plugins/minimax-ai/minimax-bot/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "minimax-bot",
"version": "0.7.4",
"version": "0.7.5",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

请 rebase 后在 main 当时的版本号上再升一号,与另外两处保持一致。

"private": true,
"type": "module",
"mcode": {
Expand Down
70 changes: 70 additions & 0 deletions plugins/minimax-ai/minimax-bot/tests/turn-retry-backoff.test.mjs
Original file line number Diff line number Diff line change
@@ -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}`);
}
});