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.7",
"version": "0.7.8",
"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 @@ -30,6 +30,12 @@ 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 Safer automatic retries

- 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

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
45 changes: 34 additions & 11 deletions plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,26 @@ 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];
export const TURN_RETRY_DELAYS_MS = Object.freeze([8000, 20000]);
/** 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']);
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) {
Expand Down Expand Up @@ -504,14 +521,17 @@ export class Engine {
let result;
let failure = '';
let failureCode = '';
let failureRetryable = false;
/** @type {unknown} */
let failureRetryable;
let failureReason = '';
let attempt = 0;
try {
for (;;) {
result = undefined;
failure = '';
failureCode = '';
failureRetryable = false;
failureRetryable = undefined;
failureReason = '';
if (controller.signal.aborted || this.isStale(ticket)) {
controller.abort();
result = skippedTurn();
Expand All @@ -535,25 +555,28 @@ export class Engine {
} catch (retryError) {
failure = describeAgentError(retryError);
failureCode = errorCode(retryError);
failureRetryable = Boolean(/** @type {any} */ (retryError)?.retryable);
failureRetryable = /** @type {any} */ (retryError)?.retryable;
failureReason = String(/** @type {any} */ (retryError)?.reason ?? '');
}
} else {
failure = describeAgentError(error);
failureCode = errorCode(error);
failureRetryable = Boolean(/** @type {any} */ (error)?.retryable);
failureRetryable = /** @type {any} */ (error)?.retryable;
failureReason = String(/** @type {any} */ (error)?.reason ?? '');
}
}
const failed = Boolean(failure) ? failureRetryable : result?.status === 'error';
const reason = result?.error?.reason;
const thrown = Boolean(failure);
const failed = thrown || result?.status === 'error';
const canRetry =
failed &&
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 < this.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];
attempt += 1;
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.7' },
serverInfo: { name: 'minimax-bot', version: '0.7.8' },
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.7",
"version": "0.7.8",
"private": true,
"type": "module",
"mcode": {
Expand Down
38 changes: 38 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,38 @@
// 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';
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, NO_RETRY_REASONS, TURN_RETRY_DELAYS_MS, mayRetryFailure } from '../miniapp/node/engine.mjs';

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 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('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);
}
}
});

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);
});
118 changes: 118 additions & 0 deletions plugins/minimax-ai/minimax-bot/tests/turn-retry-veto.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});