Skip to content
Merged
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.24",
"version": "0.7.25",
"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
6 changes: 6 additions & 0 deletions plugins/minimax-ai/minimax-bot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,12 @@ The hand-over between two Bots of a group carried the tail of the previous reply
- A `state.json` or a chat file that cannot be parsed no longer keeps the Mini App from starting. The unreadable file is kept as `*.corrupt-<time>` instead of being thrown away, and the store starts from the defaults: an unreadable chat starts empty, the other chats still load. The file was written with a rename, but nothing was flushed before it, so a power loss could leave an empty file behind; the bytes are flushed before the rename now, and the directory after it where the platform allows it.
- A failed ledger write no longer fails the turn that already ran. The reply keeps its outcome, a group round goes on to the next member, and a finished background job still saves its status and delivers its report. The miss goes to the log.

## 0.7.25 A failed turn that says what happened

- A turn whose Agent never started says so, says that the turn did not run, and names the next step: try again, or restart MiniMax Code. It used to say only that the Agent was temporarily unavailable, which read the same as a busy model. The server and the page carry the same sentence for it, so a chat and a stored message say the same thing in both languages.
- A failure whose cause the plugin cannot read — a Host error it has no sentence for — now has a sentence of its own: the turn did not finish and the reason was not identified. Where the Host throws that error, the text it came with stays out of the chat, so a session id or a service name is not shown to the user. A Host that reports the failure as a result keeps showing the text it sent.
- The errors the plugin raises itself keep the message they were built with, and every code it knows keeps its own sentence. No code was added or renamed, and nothing is retried differently: only the words a failed turn shows changed.

## 0.7.10 Optional group notes

- A group can keep a note of what it said. After every round the group records the closing lines of the replies, and every Bot of the group gets that record in its standing brief.
Expand Down
4 changes: 2 additions & 2 deletions plugins/minimax-ai/minimax-bot/miniapp/client/strings-app.js
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ export default {
'notice.failed': '出错了:{error}',
'agentError.BUSY': '同时运行的 Bot 太多了,请稍后再试',
'agentError.NOT_PERMITTED': 'Mini App 没有运行 Agent 的权限',
'agentError.AGENT_UNAVAILABLE': 'Agent 暂时不可用,请稍后再试',
'agentError.AGENT_UNAVAILABLE': 'Agent 没能启动,这一轮没有开始;稍后再试,或重启 MiniMax Code',
'agentError.AGENT_OUTCOME_UNKNOWN': '无法确认这一轮是否已完成,请先查看会话再决定是否重发',
'agentError.REQUEST_CANCELLED': '已取消',
'agentError.SESSION_NOT_FOUND': '会话不存在',
Expand Down Expand Up @@ -543,7 +543,7 @@ export default {
'notice.failed': 'Something went wrong: {error}',
'agentError.BUSY': 'Too many Bots are running at once. Try again shortly',
'agentError.NOT_PERMITTED': "The Mini App isn't allowed to run Agents",
'agentError.AGENT_UNAVAILABLE': 'The Agent is temporarily unavailable. Try again shortly',
'agentError.AGENT_UNAVAILABLE': "The Agent didn't start, so this turn didn't run. Try again later, or restart MiniMax Code",
'agentError.AGENT_OUTCOME_UNKNOWN': "Couldn't confirm whether this turn finished. Check the session before resending",
'agentError.REQUEST_CANCELLED': 'Cancelled',
'agentError.SESSION_NOT_FOUND': 'The session no longer exists',
Expand Down
8 changes: 6 additions & 2 deletions plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { stat } from 'node:fs/promises';
import { join } from 'node:path';

import { t } from './i18n.mjs';
import { storageBudget } from './memory.mjs';
import { MemoryError, storageBudget } from './memory.mjs';
import { selectMemoryContext } from './memory-context.mjs';
import { UserInbox } from './user-inbox.mjs';
import { TaskDispatcher } from './task-dispatcher.mjs';
Expand Down Expand Up @@ -2041,5 +2041,9 @@ export function describeAgentError(error) {
const key = `agentError.${code}`;
const text = code ? t(key) : key;
if (text !== key) return text;
return errorText(error) || t('agentError.unknown');
// The plugin's own errors carry a message that was written to be read by the user; the Host's
// carry an internal identifier nobody can act on. Showing the first and hiding the second
// keeps a session id or a service name out of the chat.
if (error instanceof EngineError || error instanceof MemoryError) return errorText(error);
return t('agentError.hostError');
}
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 @@ -662,7 +662,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.24' },
serverInfo: { name: 'minimax-bot', version: '0.7.25' },
instructions: t('mcp.instructions'),
};
break;
Expand Down
6 changes: 4 additions & 2 deletions plugins/minimax-ai/minimax-bot/miniapp/node/strings.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -180,12 +180,13 @@ export default {
'err.relayTooDeep': 'Bot 之间的转交层数太深了,请直接完成当前工作',
'agentError.BUSY': '同时运行的 Bot 太多了,请稍后再试',
'agentError.NOT_PERMITTED': 'Mini App 没有运行 Agent 的权限',
'agentError.AGENT_UNAVAILABLE': 'Agent 暂时不可用,请稍后再试',
'agentError.AGENT_UNAVAILABLE': 'Agent 没能启动,这一轮没有开始;稍后再试,或重启 MiniMax Code',
'agentError.AGENT_OUTCOME_UNKNOWN': '无法确认这一轮是否已完成,请先查看会话再决定是否重发',
'agentError.REQUEST_CANCELLED': '已取消',
'agentError.SESSION_NOT_FOUND': '会话不存在',
'agentError.INVALID_ARGUMENTS': '答案格式不正确,请检查后重新提交',
'agentError.unknown': '未知错误',
'agentError.hostError': '这一轮没能完成,原因没能确认;稍后再试一次',
'agentError.turnFailed': '这一轮没跑完,多半是模型服务繁忙或报错。点「重试」再来一次;经常出现的话,在 Bot 设置里给它换个模型',
'agentError.reason.rate_limited': '模型服务现在太忙了(限流),暂时接不上。点「重试」再来一次,或在 Bot 设置里换个模型',
'agentError.reason.overloaded': '模型服务过载,暂时接不上。点「重试」再来一次,或在 Bot 设置里换个模型',
Expand Down Expand Up @@ -502,12 +503,13 @@ export default {
'err.relayTooDeep': 'Too many hand-offs between Bots. Finish the current work directly',
'agentError.BUSY': 'Too many Bots are running at once. Try again shortly',
'agentError.NOT_PERMITTED': "The Mini App isn't allowed to run Agents",
'agentError.AGENT_UNAVAILABLE': 'The Agent is temporarily unavailable. Try again shortly',
'agentError.AGENT_UNAVAILABLE': "The Agent didn't start, so this turn didn't run. Try again later, or restart MiniMax Code",
'agentError.AGENT_OUTCOME_UNKNOWN': "Couldn't confirm whether this turn finished. Check the session before resending",
'agentError.REQUEST_CANCELLED': 'Cancelled',
'agentError.SESSION_NOT_FOUND': 'The session no longer exists',
'agentError.INVALID_ARGUMENTS': 'The answer was rejected. Check it and submit again',
'agentError.unknown': 'Unknown error',
'agentError.hostError': "This turn didn't finish and the reason wasn't identified. Try again later",
'agentError.turnFailed': "This turn didn't finish, most likely because the model service was busy or returned an error. Press Retry; if it keeps happening, pick another model in the Bot's settings",
'agentError.reason.rate_limited': "The model service is too busy right now (rate limited). Press Retry, or pick another model in the Bot's settings",
'agentError.reason.overloaded': "The model service is overloaded right now. Press Retry, or pick another model in the Bot's settings",
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.24",
"version": "0.7.25",
"private": true,
"type": "module",
"mcode": {
Expand Down
201 changes: 201 additions & 0 deletions plugins/minimax-ai/minimax-bot/tests/agent-error-copy.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
// @ts-check
// The words a failed turn shows the user, and the keys behind them.
//
// A turn that died for a reason the plugin cannot read used to say "try again shortly": no
// cause, no next step, nothing the user could act on. The copy here replaces that with three
// different sentences for three different situations — a code the plugin knows, an error the
// plugin raised itself, and a failure whose cause only the Host knows — so the user can tell a
// busy model from a session that never started.
//
// The page localises the codes it knows from its own dictionary, so a code the server and the
// page spell differently is a sentence the user reads twice, in two languages, with two
// meanings. The dictionaries are checked against each other here rather than by eye.

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 SERVER from '../miniapp/node/strings.mjs';
import CLIENT from '../miniapp/client/strings-app.js';
import { t, setLocale } from '../miniapp/node/i18n.mjs';
import { describeAgentError, Engine, EngineError } from '../miniapp/node/engine.mjs';
import { MemoryError, MemoryStore } from '../miniapp/node/memory.mjs';
import { Store } from '../miniapp/node/store.mjs';

/** The sentence for a turn whose Agent never started. */
const UNAVAILABLE = 'agentError.AGENT_UNAVAILABLE';
/** The sentence for a failure whose cause the plugin cannot read. */
const HOST_ERROR = 'agentError.hostError';
/** The sentence for a model that cannot be used; it names a setting, so it is not a fallback. */
const MODEL_UNAVAILABLE = 'agentError.MODEL_UNAVAILABLE';

/** The model sentence, written out: it may not drift while the other copy is being changed. */
const MODEL_TEXT = {
zh: '这个 Bot 设置的模型现在用不了(可能已停用或下线),请在 Bot 设置里换一个模型或改回默认',
en: "This Bot's model can't be used right now (it may have been disabled or removed). Pick another model, or go back to the default, in the Bot's settings",
};

/** Words that belong to the model sentence and would mislead a reader of the other one. */
const MODEL_WORDS = ['模型', 'model', 'settings', 'default', 'disabled', '停用', '下线'];

/** The codes the plugin knows by name, in the order the page shows them. */
const KNOWN_CODES = [
'BUSY',
'NOT_PERMITTED',
'SESSION_NOT_FOUND',
'INVALID_ARGUMENTS',
'AGENT_OUTCOME_UNKNOWN',
'REQUEST_CANCELLED',
];

/**
* A turn that fails once, with the Agent replaced by one that throws.
* @param {import('node:test').TestContext} ctx
* @param {unknown} thrown
*/
async function failedTurn(ctx, thrown) {
const dir = await mkdtemp(join(tmpdir(), 'bot-agent-error-'));
const store = new Store(dir);
const memory = new MemoryStore(dir);
await store.init();
await memory.init();
const engine = new Engine({
store,
memory,
logger: { warn() {} },
broadcast() {},
retryDelaysMs: [],
});
engine.agent = {
async run() {
throw thrown;
},
};
ctx.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}`;
await engine.runBotTurn({ bot, chatId, text: 'Hello', kind: 'private' });
const messages = await store.getMessages(chatId);
return messages.at(-1);
}
test('the page and the server spell the unavailable sentence the same way', () => {
assert.equal(SERVER.zh[UNAVAILABLE], CLIENT.zh[UNAVAILABLE]);
assert.equal(SERVER.en[UNAVAILABLE], CLIENT.en[UNAVAILABLE]);
});

test('every dictionary carries the same keys in both languages', () => {
assert.deepEqual(Object.keys(SERVER.zh).sort(), Object.keys(SERVER.en).sort());
assert.deepEqual(Object.keys(CLIENT.zh).sort(), Object.keys(CLIENT.en).sort());
});

test('the page reads no error key the server does not have', () => {
const server = new Set(Object.keys(SERVER.zh).filter((key) => key.startsWith('agentError.')));
const page = Object.keys(CLIENT.zh).filter((key) => key.startsWith('agentError.'));
assert.ok(page.length > 0);
for (const key of page) assert.ok(server.has(key), `the page has ${key} and the server does not`);
});

test('the server has a sentence for a cause it cannot read, in both languages', () => {
for (const lang of ['zh', 'en']) {
const text = SERVER[lang][HOST_ERROR];
assert.ok(typeof text === 'string' && text.length > 0, `${lang} has no ${HOST_ERROR}`);
assert.notEqual(text, HOST_ERROR);
}
});

test('the two new sentences differ between the two languages', () => {
assert.notEqual(SERVER.zh[UNAVAILABLE], SERVER.en[UNAVAILABLE]);
assert.notEqual(SERVER.zh[HOST_ERROR], SERVER.en[HOST_ERROR]);
});

test('the unavailable sentence does not borrow the model sentence', () => {
for (const lang of ['zh', 'en']) {
assert.notEqual(SERVER[lang][UNAVAILABLE], SERVER[lang][MODEL_UNAVAILABLE]);
const lower = SERVER[lang][UNAVAILABLE].toLowerCase();
for (const word of MODEL_WORDS) {
assert.ok(!lower.includes(word.toLowerCase()), `the ${lang} unavailable sentence mentions ${word}`);
}
}
});

test('every new sentence stays short, alone and behind the failure prefix', () => {
for (const lang of ['zh', 'en']) {
for (const key of [UNAVAILABLE, HOST_ERROR]) {
const text = SERVER[lang][key];
assert.ok(text.length <= 120, `${lang} ${key} is ${text.length} characters`);
const shown = t('notice.failed', { error: text }, lang);
assert.ok(shown.length <= 120, `${lang} ${key} under notice.failed is ${shown.length} characters`);
}
}
});

test('the model sentence is the one that was there before', () => {
assert.equal(SERVER.zh[MODEL_UNAVAILABLE], MODEL_TEXT.zh);
assert.equal(SERVER.en[MODEL_UNAVAILABLE], MODEL_TEXT.en);
});

test('a Host error with a cause the plugin cannot read is described without its text', () => {
for (const lang of ['zh', 'en']) {
setLocale(lang);
const text = describeAgentError({ code: 'SessionServiceError', message: 'SessionServiceError: svc-abc123 bind failed' });
assert.equal(text, t(HOST_ERROR));
for (const leak of ['svc-abc123', 'SessionServiceError', 'bind']) {
assert.ok(!text.includes(leak), `the sentence leaks ${leak}`);
}
}
});

test('a failure that is not an error at all is described without its text', () => {
for (const lang of ['zh', 'en']) {
setLocale(lang);
for (const thrown of [null, undefined, 'boom', {}]) {
const text = describeAgentError(thrown);
assert.equal(text, t(HOST_ERROR));
for (const leak of ['null', 'undefined', 'boom']) {
assert.ok(!text.includes(leak), `the sentence leaks ${leak}`);
}
}
}
});

test("the plugin's own errors keep the message they were built with", () => {
for (const lang of ['zh', 'en']) {
setLocale(lang);
const expected = t('err.noAgentSupport', undefined, lang);
assert.equal(describeAgentError(new EngineError('agent_unavailable', expected)), expected);
assert.equal(describeAgentError(new MemoryError(expected)), expected);
}
});

test('a known code keeps its own sentence in both languages', () => {
for (const lang of ['zh', 'en']) {
setLocale(lang);
for (const code of KNOWN_CODES) {
const key = `agentError.${code}`;
assert.equal(describeAgentError({ code }), t(key));
}
}
});

test('a turn whose Agent never started shows the unavailable sentence and hides the cause', async (ctx) => {
setLocale('en');
const message = await failedTurn(ctx, { code: 'AGENT_UNAVAILABLE', message: 'SessionServiceError: svc-abc123 bind failed' });
assert.equal(message.errorCode, 'AGENT_UNAVAILABLE');
assert.equal(message.error, t('agentError.AGENT_UNAVAILABLE'));
assert.ok(!message.error.includes('svc-abc123'), 'the error keeps the cause');
assert.ok(!String(message.text ?? '').includes('svc-abc123'), 'the text keeps the cause');
});

test('a turn that failed for a reason the plugin cannot name shows the host sentence', async (ctx) => {
setLocale('en');
const message = await failedTurn(ctx, { code: 'SessionServiceError' });
assert.equal(message.errorCode, 'SessionServiceError');
assert.equal(message.error, t(HOST_ERROR));
assert.ok(!message.error.includes('SessionServiceError'), 'the error keeps the code');
});
Loading