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.15",
"version": "0.7.17",
"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 @@ -44,6 +44,12 @@ The MCP tools check the arguments they declare before they run. A value of the w

An avatar may come from the avatar library or from an image uploaded in the page only. A path on the computer is refused, the plugin reads no file outside its own `dataDir` for one, and the refused path is not echoed back — so the field cannot be used to ask what exists on the disk.

## 0.7.17 The panel and the ledger say what happened

The usage ledger is the record of what the model really did. A turn cut off by a crash or a restart is written down as aborted, with no duration — it never reported how long it ran — and with the characters and tool calls it did make; the panel's average is taken over the turns that have a known duration, so a lost turn neither vanishes from the count nor counts as zero seconds. Every row says how many times the model was called for that turn (a retry is still one turn), and the panel adds that count when it is higher than the number of turns. A turn that ran is on the ledger even when a later step of the same turn fails to write.

The note section of the group panel reads the note's limits (6 entries and 1200 characters) from the server, so the numbers it shows are the numbers the plugin uses.

## 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
19 changes: 19 additions & 0 deletions plugins/minimax-ai/minimax-bot/miniapp/client/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ export const state = {
user: { name: '', systemName: '' },
/** Effective language resolved by the server. */
locale: systemLocale(),
/** Limits the server applies to a group note; `null` until the server states them. */
/** @type {{ entries: number, chars: number } | null} */
noteLimits: null,
};

/** Navigation callbacks registered by app.js so panels can drive the main view. */
Expand Down Expand Up @@ -255,6 +258,19 @@ export async function api(method, path, body) {
return /** @type {any} */ (data);
}

/**
* The note limits as the server states them, or `null` when it does not state a usable pair:
* the panel then says nothing at all rather than half a line.
* @param {any} limits
* @returns {{ entries: number, chars: number } | null}
*/
function noteLimitsOf(limits) {
if (!limits || typeof limits !== 'object') return null;
const { entries, chars } = limits;
if (!Number.isFinite(entries) || !Number.isFinite(chars)) return null;
return { entries, chars };
}

export async function loadState() {
const data = await api('GET', '/api/state');
state.bots = data.bots;
Expand All @@ -271,6 +287,9 @@ export async function loadState() {
state.settings = data.settings ?? state.settings;
state.user = data.user ?? state.user;
state.locale = data.locale ?? state.locale;
// The server owns the note limits: the panel states them instead of writing down numbers of
// its own, which would drift from the ones the plugin actually applies.
state.noteLimits = noteLimitsOf(data.noteLimits);
setLocale(state.locale);
if (!state.current || !chatExists(state.current)) {
const remembered = prefs.lastChat && chatExists(prefs.lastChat) ? prefs.lastChat : '';
Expand Down
7 changes: 5 additions & 2 deletions plugins/minimax-ai/minimax-bot/miniapp/client/panels.js
Original file line number Diff line number Diff line change
Expand Up @@ -1473,13 +1473,16 @@ function usagePage(body, o) {
.then((data) => {
if (state.overlay?.kind !== 'settings' || state.overlay.page !== 'usage') return;
const totals = data.totals;
// A turn that was cut off has no known duration, so the average counts the turns that
// really ran; the count of model calls says how often the model was really asked.
const modelCalls = totals.modelCalls ?? totals.turns;
const kpis = h(
'div',
{ class: 'kpis' },
h('div', { class: 'kpi big' }, h('span', { class: 'k-label' }, days === 1 ? t('panels.usage.last24h') : t('panels.usage.lastDays', { n: days })), h('span', { class: 'k-value' }, fmtNum(totals.turns), h('span', { class: 'k-unit' }, t('panels.usage.turnsUnit', { count: totals.turns })))),
h('div', { class: 'kpi big' }, h('span', { class: 'k-label' }, days === 1 ? t('panels.usage.last24h') : t('panels.usage.lastDays', { n: days })), h('span', { class: 'k-value' }, fmtNum(totals.turns), h('span', { class: 'k-unit' }, t('panels.usage.turnsUnit', { count: totals.turns }), modelCalls > totals.turns ? ` · ${t('panels.usage.modelCalls', { n: modelCalls, count: modelCalls })}` : ''))),
h('div', { class: 'kpi' }, h('span', { class: 'k-label' }, t('panels.usage.replyChars')), h('span', { class: 'k-value' }, fmtNum(totals.chars))),
h('div', { class: 'kpi' }, h('span', { class: 'k-label' }, t('panels.usage.toolCalls')), h('span', { class: 'k-value' }, fmtNum(totals.tools))),
h('div', { class: 'kpi' }, h('span', { class: 'k-label' }, t('panels.usage.avgDuration')), h('span', { class: 'k-value' }, totals.turns ? fmtDuration(totals.durationMs / totals.turns) : '—')),
h('div', { class: 'kpi' }, h('span', { class: 'k-label' }, t('panels.usage.avgDuration')), h('span', { class: 'k-value' }, totals.avgDurationMs != null ? fmtDuration(totals.avgDurationMs) : '—'), totals.durationSamples < totals.turns ? h('small', { class: 'muted' }, t('panels.usage.avgDurationNote')) : null),
);
holder.replaceChildren(
kpis,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -346,9 +346,11 @@ export default {
'panels.usage.last24h': '最近 24 小时',
'panels.usage.lastDays': '最近 {n} 天',
'panels.usage.turnsUnit': '轮对话',
'panels.usage.modelCalls': '{n} 次模型调用',
'panels.usage.replyChars': '回复字数',
'panels.usage.toolCalls': '工具调用',
'panels.usage.avgDuration': '平均耗时',
'panels.usage.avgDurationNote': '仅统计有已知耗时的轮次',
'panels.usage.turnsTitle': '对话轮次',
'panels.usage.hourly': '按小时',
'panels.usage.daily': '按天',
Expand Down Expand Up @@ -709,9 +711,11 @@ export default {
'panels.usage.last24h': 'Last 24 hours',
'panels.usage.lastDays': 'Last {n} days',
'panels.usage.turnsUnit': (p) => pl(p.count, 'turn', 'turns'),
'panels.usage.modelCalls': '{n} model calls',
'panels.usage.replyChars': 'Reply characters',
'panels.usage.toolCalls': 'Tool calls',
'panels.usage.avgDuration': 'Avg. duration',
'panels.usage.avgDurationNote': 'Turns with a known duration only',
'panels.usage.turnsTitle': 'Chat turns',
'panels.usage.hourly': 'Hourly',
'panels.usage.daily': 'Daily',
Expand Down
74 changes: 53 additions & 21 deletions plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ const HELPER_ACTIVE = new Set(['queued', 'running']);
* Model capacity errors (429/529) and timeouts usually clear within a minute.
*/
export const TURN_RETRY_DELAYS_MS = Object.freeze([8000, 20000]);
/**
* How often a running turn writes its progress to disk. A crash must leave the work the model
* already did in the ledger, so the turn's known state cannot live only in memory.
*/
const LIVE_PERSIST_MS = 2000;
/** Failure reasons a retry cannot fix; anything else (or no reason from an older Host) may be retried. */
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. */
Expand Down Expand Up @@ -578,11 +583,14 @@ export class Engine {

const startedAt = Date.now();
let lastFlush = 0;
let lastPersist = 0;
let rootSessionId = this.store.getSession(chatId, bot.id)?.sessionId;
let toolStarted = false;
/** Whether any attempt started a turn, i.e. the session already holds this input. */
let reachedHost = false;
let invoked = false;
/** How many times the model was called for this turn, retries included. */
let modelCalls = 0;
let preparedInput = input;
let acceptedGroupCursor;
/** @type {{ type: 'text', text: string, live?: boolean } | undefined} */
Expand All @@ -593,6 +601,13 @@ export class Engine {
lastFlush = now;
message.text = textOf(message.parts);
this.broadcast({ type: 'message.upsert', chatId, message });
// Keep the turn's progress on disk: a crash must leave the work the model already did
// in the ledger, not just a message that says it was still running.
if (now - lastPersist >= LIVE_PERSIST_MS) {
lastPersist = now;
void this.store.persistMessages(chatId).catch((error) =>
this.logger.warn('minimax_bot.turn_persist_failed', { code: error?.code ?? 'unknown' }));
}
};
const ensureLiveText = () => {
if (!liveText || message.parts.at(-1) !== liveText) {
Expand Down Expand Up @@ -709,13 +724,17 @@ export class Engine {
if (attempt === 0) preparedInput = await this.prepareGroupInput(input);
const attemptInput = attempt === 0 || !reachedHost ? preparedInput : { ...preparedInput, text: t('retry.continue') };
invoked = true;
modelCalls += 1;
message.modelCalls = modelCalls;
result = await this.invoke(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.
await this.store.clearSession(chatId, bot.id);
try {
preparedInput = await this.prepareGroupInput(input);
modelCalls += 1;
message.modelCalls = modelCalls;
result = await this.invoke(preparedInput, undefined, controller.signal, onEvent) ?? skippedTurn();
} catch (retryError) {
failure = describeAgentError(retryError);
Expand Down Expand Up @@ -814,31 +833,37 @@ export class Engine {
request: result.pendingQuestion.request,
};
}
await this.store.updateMessage(chatId, message.id, {});
if (result?.sessionId) {
await this.store.setSession(chatId, bot.id, { sessionId: result.sessionId });
}
if (acceptedGroupCursor !== undefined) {
const previous = this.store.getSession(chatId, bot.id)?.lastSeenSeq ?? 0;
await this.store.setSession(chatId, bot.id, { lastSeenSeq: Math.max(previous, acceptedGroupCursor) });
const tools = message.parts.filter((part) => part.type === 'tool').length;
try {
await this.store.updateMessage(chatId, message.id, {});
if (result?.sessionId) {
await this.store.setSession(chatId, bot.id, { sessionId: result.sessionId });
}
if (acceptedGroupCursor !== undefined) {
const previous = this.store.getSession(chatId, bot.id)?.lastSeenSeq ?? 0;
await this.store.setSession(chatId, bot.id, { lastSeenSeq: Math.max(previous, acceptedGroupCursor) });
}
} finally {
// The model ran: the ledger must say so even when a later step of this turn fails.
await this.store.recordUsage({
ts: Date.now(),
turnId: message.id,
botId: bot.id,
botName: bot.name,
chatId,
kind: input.kind,
status,
durationMs: message.durationMs,
chars: finalText.length,
tools,
// One turn, but the model may have been called more than once to finish it.
attempts: modelCalls,
});
this.broadcast({ type: 'usage.changed' });
}
this.broadcast({ type: 'message.upsert', chatId, message });
this.emitBusy(chatId);
this.broadcastSummary(chatId);

const tools = message.parts.filter((part) => part.type === 'tool').length;
await this.store.recordUsage({
ts: Date.now(),
botId: bot.id,
botName: bot.name,
chatId,
kind: input.kind,
status,
durationMs: message.durationMs,
chars: finalText.length,
tools,
});
this.broadcast({ type: 'usage.changed' });
return {
status,
text: finalText,
Expand Down Expand Up @@ -1755,6 +1780,10 @@ export class Engine {
// A background job calls the same tools a Bot's own turn does. Count them, or the
// turn it spends looks like it cost nothing.
turnTools += 1;
// Keep the count on disk: a job cut off by a restart must still show the tools it
// really called in the ledger.
helper.turnTools = turnTools;
void this.store.save().catch((error) => this.logger.warn('minimax_bot.helper_save_failed', { helperId: helper.id, code: error?.code ?? 'unknown' }));
} else if (event.type === 'permission.requested') {
// The user approves a helper's tool call in its Bot's chat, like the Bot's own.
const approval = {
Expand Down Expand Up @@ -1833,6 +1862,7 @@ export class Engine {
// counts fewer turns than the model actually ran.
await this.store.recordUsage({
ts: Date.now(),
turnId: helper.id,
botId: helper.botId,
botName: turnBotName,
chatId: helper.chatId,
Expand All @@ -1841,6 +1871,8 @@ export class Engine {
durationMs: turnMs,
chars: turnText.length,
tools: turnTools,
// A background job runs the model once; it has no retry loop of its own.
attempts: 1,
});
this.broadcast({ type: 'usage.changed' });
}
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.15' },
serverInfo: { name: 'minimax-bot', version: '0.7.17' },
instructions: t('mcp.instructions'),
};
break;
Expand Down
17 changes: 15 additions & 2 deletions plugins/minimax-ai/minimax-bot/miniapp/node/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ export async function start(context) {
}
/** @type {Record<string, any>} */
const byBot = {};
const totals = { turns: 0, chars: 0, tools: 0, durationMs: 0, errors: 0 };
const totals = { turns: 0, chars: 0, tools: 0, durationMs: 0, durationSamples: 0, modelCalls: 0, errors: 0 };
for (const row of rows) {
const bucket = buckets[keyOf(row.ts)];
const live = store.getBot(row.botId);
Expand All @@ -244,6 +244,8 @@ export async function start(context) {
chars: 0,
tools: 0,
durationMs: 0,
durationSamples: 0,
modelCalls: 0,
errors: 0,
});
if (bucket) {
Expand All @@ -254,13 +256,24 @@ export async function start(context) {
target.turns += 1;
target.chars += row.chars ?? 0;
target.tools += row.tools ?? 0;
target.durationMs += row.durationMs ?? 0;
// A turn that was cut off has no known duration: it must not pull the average down,
// so only the turns that really ran are counted.
if (Number.isFinite(row.durationMs)) {
target.durationMs += row.durationMs;
target.durationSamples += 1;
}
// How many times the model really ran for this turn; a row without the field is one call.
target.modelCalls += row.attempts ?? 1;
}
if (row.status === 'error') {
bot.errors += 1;
totals.errors += 1;
}
}
// The average covers the turns whose duration is known; a turn that was cut off has none.
for (const target of [totals, ...Object.values(byBot)]) {
target.avgDurationMs = target.durationSamples ? target.durationMs / target.durationSamples : null;
}
return {
days: hourly ? 1 : span,
hourly,
Expand Down
Loading
Loading