diff --git a/plugins/minimax-ai/minimax-bot/.minimax-plugin/plugin.json b/plugins/minimax-ai/minimax-bot/.minimax-plugin/plugin.json index 1fe4e75..515b038 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.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", diff --git a/plugins/minimax-ai/minimax-bot/README.md b/plugins/minimax-ai/minimax-bot/README.md index c287ee8..31261d3 100644 --- a/plugins/minimax-ai/minimax-bot/README.md +++ b/plugins/minimax-ai/minimax-bot/README.md @@ -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. diff --git a/plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs b/plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs index ba35635..d74983e 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs +++ b/plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs @@ -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) { @@ -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(); @@ -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; diff --git a/plugins/minimax-ai/minimax-bot/miniapp/node/mcp.mjs b/plugins/minimax-ai/minimax-bot/miniapp/node/mcp.mjs index b33cf4e..1598897 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.7' }, + serverInfo: { name: 'minimax-bot', version: '0.7.8' }, instructions: t('mcp.instructions'), }; break; diff --git a/plugins/minimax-ai/minimax-bot/package.json b/plugins/minimax-ai/minimax-bot/package.json index fc67f58..cc345cb 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.7", + "version": "0.7.8", "private": true, "type": "module", "mcode": { 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 0000000..163b766 --- /dev/null +++ b/plugins/minimax-ai/minimax-bot/tests/turn-retry-backoff.test.mjs @@ -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); +}); 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 0000000..27a57be --- /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); +});