Skip to content
Open
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.20",
"version": "0.7.21",
"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
10 changes: 10 additions & 0 deletions plugins/minimax-ai/minimax-bot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,16 @@ Two bounds are worth knowing. The archive keeps at most 1,000 records, so a `req

A group created before 0.7.8 can hold an admin that is not on its member list. Queueing that admin hands the group a speaker it then skips, so every message without an `@` went quiet: the reply was marked cancelled and nothing explained it. The queue is now built from the member list only — the admin speaks first when it is on the list, the first member takes over when it is not, and in the `all` mode only real members are queued. An `@` still beats every reply mode.

## 0.7.21 The mention sign, the admin's seat, the queued work and the clock

The mention rule of the section above left one sign out, and three more corners are fixed. A full-width keyboard writes the full-width `@`, which is the same sign as the ASCII `@`: a mention that starts with one now reaches the member like any other (`@某人` calls that member), and a Bot or group name may no longer carry the sign.

Only the main Bot or the operator may hand a group's admin over. A group admin who patched the member list and left themselves out used to move the admin to the first member left, while the very same change through the group's admin field was refused. Both doors now ask the same question, and the refused one says why.

A shutdown no longer throws away work that never started. Such a task stays queued and runs again after a restart, as the section on reliable delegation says; before, it was written down as `unknown`, a state that cannot be cancelled, so the work was lost. Work the Host had already taken is not replayed either: it stays `unknown`. A task the user cancelled stays cancelled, a task whose result is already there settles by that result instead of being marked `unknown`, and a turn that ended in a question stays answerable instead of being written off as unknown.

The usage ledger counts the time the model really ran. A retried turn waits between the attempts, and that wait was counted as the model's time: a turn that waited half a minute between two attempts was written down as a turn of half a minute, and the panel averaged that up. The duration is now the time the model ran and the panel's average follows it; the wait between the attempts is kept beside it in the ledger (`usage.jsonl`), not inside the duration. A turn the model never ran has no duration to report, so it is left out of the average instead of counting as zero seconds.

## 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
45 changes: 40 additions & 5 deletions plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -581,7 +581,6 @@ export class Engine {
this.running.set(key, entry);
this.emitBusy(chatId);

const startedAt = Date.now();
let lastFlush = 0;
let lastPersist = 0;
let rootSessionId = this.store.getSession(chatId, bot.id)?.sessionId;
Expand All @@ -591,6 +590,12 @@ export class Engine {
let invoked = false;
/** How many times the model was called for this turn, retries included. */
let modelCalls = 0;
/** How long the model really ran: the wait between two attempts is not its time. */
let modelMs = 0;
/** Whether the model really ran for this turn. A turn it never ran has no duration. */
let modelRan = false;
/** How long the turn waited between two attempts, the backoff. */
let sleptMs = 0;
let preparedInput = input;
let acceptedGroupCursor;
/** @type {{ type: 'text', text: string, live?: boolean } | undefined} */
Expand Down Expand Up @@ -623,6 +628,8 @@ export class Engine {
case 'turn.started':
// The Host has the message now: a later retry need not (and must not) send it again.
reachedHost = true;
// The model is running for this turn, so it has a duration to report.
modelRan = true;
acceptedGroupCursor = preparedInput.groupCursor;
rootSessionId = event.sessionId;
message.sessionId = event.sessionId;
Expand Down Expand Up @@ -705,6 +712,23 @@ export class Engine {
let failureRetryable;
let failureReason = '';
let attempt = 0;
/**
* Calls the Host and times it: the ledger's duration is the time the model ran, and the
* wait between two attempts is not part of it. `undefined` means the Host was never called
* (the turn was stopped before the call), and a thrown error means the call failed.
* Whether the model ran at all is not decided here: a call that returns is not a turn that
* ran — the Host accepts an answer without starting a continuation turn — so only the Host
* reaching the turn (`turn.started`) says that the model really ran.
*/
const callModel = async (...args) => {
const callStartedAt = Date.now();
try {
return await this.invoke(...args);
} finally {
// Only the time the model really ran belongs to the turn's duration.
modelMs += Date.now() - callStartedAt;
}
};
try {
for (;;) {
result = undefined;
Expand All @@ -726,7 +750,7 @@ export class Engine {
invoked = true;
modelCalls += 1;
message.modelCalls = modelCalls;
result = await this.invoke(attemptInput, rootSessionId, controller.signal, onEvent) ?? skippedTurn();
result = await callModel(attemptInput, rootSessionId, controller.signal, onEvent) ?? skippedTurn();
} catch (error) {
if (errorCode(error) === 'SESSION_NOT_FOUND' && !input.answer && attempt === 0) {
// The Agent session was deleted; start a fresh one and resend the Bot's context.
Expand All @@ -735,7 +759,7 @@ export class Engine {
preparedInput = await this.prepareGroupInput(input);
modelCalls += 1;
message.modelCalls = modelCalls;
result = await this.invoke(preparedInput, undefined, controller.signal, onEvent) ?? skippedTurn();
result = await callModel(preparedInput, undefined, controller.signal, onEvent) ?? skippedTurn();
} catch (retryError) {
failure = describeAgentError(retryError);
failureCode = errorCode(retryError);
Expand Down Expand Up @@ -769,7 +793,10 @@ export class Engine {
liveText = undefined;
message.live = { key: 'live.retrying', params: { s: Math.round(delay / 1000), n: attempt + 1, max: this.retryDelays.length + 1 } };
flush(true);
const sleepStartedAt = Date.now();
const waited = await sleepUnlessAborted(delay, controller.signal);
// The wait between two attempts belongs to the turn, not to the model.
sleptMs += Date.now() - sleepStartedAt;
message.live = undefined;
if (!waited) {
// Stopped while waiting to retry: end as the stop it was, not as the failure before it.
Expand Down Expand Up @@ -798,7 +825,10 @@ export class Engine {
message.text = finalText;
message.status = status;
message.live = undefined;
message.durationMs = Date.now() - startedAt;
// The ledger says what the model really did: the duration is the time the model ran, not
// the time the turn waited between two attempts. A turn the model never ran has no
// duration to report, so it is left out of the average instead of counting as zero.
message.durationMs = modelRan ? modelMs : null;
if (failure) {
message.error = failure;
// The page localises known codes; `error` keeps the text for anything else.
Expand Down Expand Up @@ -858,6 +888,8 @@ export class Engine {
tools,
// One turn, but the model may have been called more than once to finish it.
attempts: modelCalls,
// The wait between two attempts is recorded on its own: it is not the model's time.
sleptMs,
});
this.broadcast({ type: 'usage.changed' });
}
Expand Down Expand Up @@ -1197,7 +1229,10 @@ export class Engine {
}

mentionsIn(text, group, excludeId) {
const lower = String(text ?? '').toLowerCase();
// A full-width IME writes the full-width "@" (and the small commercial "﹫"), and they stand
// where the ASCII "@" stands. Both are one character wide, so the text can be normalised
// without moving any other character: the offsets below stay the offsets of the original.
const lower = String(text ?? '').toLowerCase().replace(/[@﹫]/g, '@');
const members = group.memberIds.map((id) => this.store.getBot(id)).filter(Boolean)
.map((bot) => ({ id: bot.id, name: bot.name.toLowerCase() }))
.sort((a, b) => b.name.length - a.name.length);
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 @@ -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.20' },
serverInfo: { name: 'minimax-bot', version: '0.7.21' },
instructions: t('mcp.instructions'),
};
break;
Expand Down
30 changes: 23 additions & 7 deletions plugins/minimax-ai/minimax-bot/miniapp/node/task-dispatcher.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -646,7 +646,12 @@ export class TaskDispatcher {
}
await this.save();
if (run.controller.signal.aborted || this.disposed) {
task.status = this.disposed ? "unknown" : "cancelled";
// The turn never reached the Host, so it did no work. A cancelled task stays cancelled;
// a task the dispatcher was disposed for stays queued, so a restart runs it again
// instead of losing it.
task.status = this.disposed && !task.cancelRequested ? "queued" : "cancelled";
// It never ran, so it has no running time to show either.
if (task.status === "queued") delete task.startedAt;
return { status: "aborted", skipped: true, accepted: false, safeToContinue: !this.disposed };
}
let result;
Expand Down Expand Up @@ -756,13 +761,24 @@ export class TaskDispatcher {
if (text.length > MAX_TASK_RESULT) task.resultTruncated = true;
else delete task.resultTruncated;
task.error = result.errorCode ?? result.error;
if (
this.disposed ||
(result.safeToContinue === false && !result.pendingQuestion)
)
// Whether the Host ever took this turn: `accepted` is true only when the Host has the
// message or the turn produced a result of its own. A turn it did not take did no work, so
// it can be run again; a turn it took has an outcome nobody can observe any more.
const neverStarted = result.accepted === false;
if (this.disposed && neverStarted && !task.cancelRequested) {
// The dispatcher is shutting down and this turn never reached the Host: the work was
// never started, so it stays queued and a restart runs it again.
task.status = "queued";
// It never ran, so it has no running time to show either.
delete task.startedAt;
} else if (result.safeToContinue === false && !result.pendingQuestion) {
task.status = "unknown";
} else if (task.cancelRequested && (result.status !== "done" || result.pendingQuestion || this.pendingDependencies(task).length)) {
// The user asked for it to stop: a shutdown does not undo that.
task.status = "cancelled";
} else if (this.disposed && result.status !== "done") {
task.status = "unknown";
else if (task.cancelRequested && (result.status !== "done" || result.pendingQuestion || this.pendingDependencies(task).length)) task.status = "cancelled";
else if (result.pendingQuestion) task.status = "waiting_for_user";
} else if (result.pendingQuestion) task.status = "waiting_for_user";
else if (result.paused) {
task.status = "paused";
task.pauseReason = result.paused;
Expand Down
8 changes: 7 additions & 1 deletion plugins/minimax-ai/minimax-bot/miniapp/node/team.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,9 @@ export class Team {
const kind = t(`kind.${kindKey}`);
if (!value) throw new EngineError('invalid', t('err.nameEmpty', { kind }));
if (value.length > 24) throw new EngineError('invalid', t('err.nameTooLong', { kind }));
if (/[@\n\r]/.test(value)) throw new EngineError('invalid', t('err.nameChars', { kind }));
// The full-width "@" and the small commercial "﹫" are the same sign as the ASCII "@",
// so a name must not carry them either: a mention is found by its "@".
if (/[@@﹫\n\r]/.test(value)) throw new EngineError('invalid', t('err.nameChars', { kind }));
const list = kindKey === 'bot' ? this.store.state.bots : this.store.state.groups;
if (list.some((item) => item.id !== selfId && item.name.toLowerCase() === value.toLowerCase())) {
throw new EngineError('invalid', t('err.nameTaken', { name: value, kind, kindKey }));
Expand Down Expand Up @@ -420,6 +422,10 @@ export class Team {
if (patch.admin !== undefined) draft.adminId = this.resolveBot(patch.admin).id;
if (!draft.memberIds.includes(draft.adminId)) {
if (patch.admin !== undefined) draft.memberIds.unshift(draft.adminId);
// Dropping the admin from the member list moves the admin to the first member left. Only
// the main Bot may hand the admin over: another Bot would change the admin by a side
// door, which is the very thing the check above refuses.
else if (caller && !caller.isMain) throw new EngineError('forbidden', t('err.adminChangeForbidden'));
else draft.adminId = draft.memberIds[0];
}
Object.assign(group, draft);
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.20",
"version": "0.7.21",
"private": true,
"type": "module",
"mcode": {
Expand Down
90 changes: 90 additions & 0 deletions plugins/minimax-ai/minimax-bot/tests/group-admin-transfer.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// @ts-check
// Who may hand a group's admin over.
//
// Only the main Bot may change the admin of a group. The explicit `admin` field is refused for
// every other Bot, but the member list was a second door to the same change: a group admin who
// patched the member list and left themselves out moved the admin to the first member left.
// The rule is one rule, so both doors have to ask the same question.

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 { Team } from '../miniapp/node/team.mjs';
import { setLocale } from '../miniapp/node/i18n.mjs';

async function fixture(t) {
const dir = await mkdtemp(join(tmpdir(), 'bot-admin-transfer-'));
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: [],
});
const team = new Team({ store, memory, engine, broadcast() {} });
t.after(async () => {
engine.dispose();
await rm(dir, { recursive: true, force: true });
});
store.state.bots = [
{ id: 'chief', name: 'Chief', isMain: true },
{ id: 'alice', name: 'Alice' },
{ id: 'bob', name: 'Bob' },
{ id: 'carol', name: 'Carol' },
];
// Alice is the admin of the group, and she is not the main Bot.
store.state.groups = [{
id: 'grp_g', name: 'Team', adminId: 'alice',
memberIds: ['alice', 'bob'], replyMode: 'admin',
}];
await store.save();
return { store, engine, team };
}

test('a group admin cannot hand the admin over by patching the member list', async (t) => {
const { team, store } = await fixture(t);
const alice = store.getBot('alice');
await assert.rejects(
() => team.updateGroup(alice, 'grp_g', { members: ['bob'] }),
(error) => error.code === 'forbidden' && /admin/i.test(error.message),
);
// Nothing changed: the same Bot is still the admin and still a member.
const group = store.getGroup('grp_g');
assert.equal(group.adminId, 'alice');
assert.deepEqual(group.memberIds, ['alice', 'bob']);
});

test('the explicit admin field is refused for the same Bot', async (t) => {
const { team, store } = await fixture(t);
const alice = store.getBot('alice');
await assert.rejects(
() => team.updateGroup(alice, 'grp_g', { admin: 'bob' }),
(error) => error.code === 'forbidden' && /admin/i.test(error.message),
);
assert.equal(store.getGroup('grp_g').adminId, 'alice');
});

test('a group admin may still change the member list as long as the admin stays', async (t) => {
const { team, store } = await fixture(t);
const alice = store.getBot('alice');
await team.updateGroup(alice, 'grp_g', { members: ['alice', 'bob', 'carol'] });
const group = store.getGroup('grp_g');
assert.equal(group.adminId, 'alice');
assert.deepEqual(group.memberIds, ['alice', 'bob', 'carol']);
});

test('the main Bot may still move the admin by patching the member list', async (t) => {
const { team, store } = await fixture(t);
const chief = store.getBot('chief');
await team.updateGroup(chief, 'grp_g', { members: ['bob'] });
const group = store.getGroup('grp_g');
assert.equal(group.adminId, 'bob');
assert.deepEqual(group.memberIds, ['bob']);
});
Loading
Loading