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.14",
"version": "0.7.15",
"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
8 changes: 8 additions & 0 deletions plugins/minimax-ai/minimax-bot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ Text that travels from one Bot into another Bot's prompt is cleaned on the way.

Only the tag goes — the words around it stay — and the plugin's own delivery format (`<media src="…" />`) is not touched, so a report that names a file still reaches the Bot that has to hand it over.

## 0.7.15 What the plugin refuses to guess

A value the plugin declares is now checked at the door instead of being quietly turned into another legal value. A scheduled task whose `kind` is not `daily`, `interval` or `once` is refused with the list of the values it may take — leaving `kind` out is still the default daily task — and a group whose `reply_mode` is not `admin` or `all` is refused too, without touching the setting the group already had. The usage range has to be a whole number of days from 1 to 90; a value outside it gets an error instead of an empty chart that reads like the quota had been wiped.

The MCP tools check the arguments they declare before they run. A value of the wrong JSON type is refused at the door, so a call that does not fit the schema never reaches the tool and never changes anything. For the enumerations a tool answers itself — the memory scope, the group reply mode, the schedule kind — the tool's own message is the one the model reads: it names the range and what the value would have changed, which a schema cannot. A failure that is not one of the plugin's own errors answers with a plain notice now, and the detail goes to the log instead of into the model's context.

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.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
89 changes: 85 additions & 4 deletions plugins/minimax-ai/minimax-bot/miniapp/node/mcp.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { EngineError } from './engine.mjs';
import { t } from './i18n.mjs';
import { ENTRY_MAX, MemoryError, TOPIC_KEY_RE } from './memory.mjs';
import { BOT_COLORS, BOT_SKINS, effortOptions, withEffort, newId } from './store.mjs';
import { MEMORY_SCOPES } from './team.mjs';

/** @typedef {import('./team.mjs').Team} Team */
/** @typedef {import('./engine.mjs').Engine} Engine */
Expand Down Expand Up @@ -36,7 +37,7 @@ function toolDefs() {
};
const memoryScope = {
type: 'string',
enum: ['self', 'team', 'bot'],
enum: MEMORY_SCOPES,
description: t('mcp.p.scope'),
};

Expand Down Expand Up @@ -271,6 +272,67 @@ function toolDefs() {

/** Tool names, for lookups that must not depend on the language. */
export const TOOL_NAMES = toolDefs().map((tool) => tool.name);

/** Whether a value is of the JSON type a schema declares. */
function isJsonType(type, value) {
if (type === 'string') return typeof value === 'string';
if (type === 'boolean') return typeof value === 'boolean';
if (type === 'integer') return Number.isInteger(value);
if (type === 'number') return typeof value === 'number' && Number.isFinite(value);
if (type === 'array') return Array.isArray(value);
if (type === 'object') return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
return true;
}

/**
* The enums a tool answers itself.
*
* For these the tool's answer says more than the schema can: the range, and what the value
* would have changed had it been accepted. So the value reaches the tool and the model reads
* the tool's message, not a generic one. Every other declared enum is checked here, before the
* tool runs, so a value that would otherwise be read as a different legal one never changes
* anything.
*/
const TOOL_OWNED_ENUMS = new Set([
'remember.scope', 'forget.scope', 'recall.scope',
'create_group.reply_mode', 'update_group.reply_mode',
'create_schedule.kind',
]);

/**
* What is wrong with the arguments of a tool call, judged by the inputSchema the tool itself
* declares: a value of the wrong JSON type, or a value outside a declared enum. The other rules
* of a tool (a topic a memory needs, a text that may not be longer than X) belong to the tool,
* which answers them with its own message; here only the shape of a value is checked, so a
* call that does not fit the schema never reaches the tool and never changes anything.
* @param {string} tool the tool being called, for the enums the tool answers itself
* @param {any} schema
* @param {any} args
* @returns {string[]} the problems, empty when the arguments fit the schema
*/
function schemaProblems(tool, schema, args) {
if (!schema || schema.type !== 'object') return [];
if (args === null || typeof args !== 'object' || Array.isArray(args)) {
return [t('mcp.err.argObject')];
}
const problems = [];
for (const [name, value] of Object.entries(args)) {
// An argument the tool did not declare is left to the tool: dispatch_tasks, for one, has a
// message of its own for the options it does not support.
const property = schema.properties?.[name];
if (!property || value === null || value === undefined) continue;
if (property.type && !isJsonType(property.type, value)) {
problems.push(t('mcp.err.argType', { name, type: property.type }));
continue;
}
if (Array.isArray(property.enum) && !property.enum.includes(value)
&& !TOOL_OWNED_ENUMS.has(`${tool}.${name}`)) {
problems.push(t('mcp.err.argEnum', { name, values: property.enum.join(', '), value: String(value) }));
}
}
return problems;
}

/**
* @param {{ team: Team, engine: Engine }} deps
*/
Expand All @@ -296,6 +358,20 @@ export function createMcpHandler({ team, engine }) {
return bot;
}

/**
* The text a failed tool call shows the model. A failure the plugin knows (EngineError,
* MemoryError) carries its own message, written for the model. Anything else is a fault of
* the plugin: the model gets a generic line it cannot act on anyway, and the detail goes
* to the log, where the operator reads it.
*/
function failureText(error, name) {
if (error instanceof EngineError || error instanceof MemoryError) return String(error.message);
engine.logger?.warn?.('minimax_bot.tool_failed', {
tool: name, code: error?.code ?? 'unknown', detail: String(error?.message ?? error),
});
return t('mcp.err.internal');
}

const LIVE = {
// Sent as keys so the page renders them in its own language.
message_bot: (a) => ({ key: 'live.askBot', params: { name: store.getBot(a.to)?.name ?? a.to ?? t('mcp.anotherBot') } }),
Expand Down Expand Up @@ -586,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.14' },
serverInfo: { name: 'minimax-bot', version: '0.7.15' },
instructions: t('mcp.instructions'),
};
break;
Expand All @@ -606,6 +682,12 @@ export function createMcpHandler({ team, engine }) {
if (!TOOL_NAMES.includes(name)) {
return { jsonrpc: '2.0', id, error: { code: -32602, message: `Unknown tool: ${name}` } };
}
const tool = toolDefs().find((entry) => entry.name === name);
const problems = schemaProblems(name, tool?.inputSchema, args);
if (problems.length) {
result = { content: [{ type: 'text', text: t('mcp.err.badArgs', { tool: name, problems }) }], isError: true };
break;
}
const callKey = session ? `${session}:${JSON.stringify(id)}` : newId('rpc');
if (calls.has(callKey)) return { jsonrpc: '2.0', id, error: { code: -32600, message: 'Request already running' } };
const controller = new AbortController();
Expand All @@ -614,8 +696,7 @@ export function createMcpHandler({ team, engine }) {
const value = await callTool(name, args, controller.signal);
result = { content: [{ type: 'text', text: JSON.stringify(value, null, name === 'recall' ? 0 : 2) }] };
} catch (error) {
const text = error && typeof error === 'object' && 'message' in error ? String(error.message) : String(error);
result = { content: [{ type: 'text', text }], isError: true };
result = { content: [{ type: 'text', text: failureText(error, name) }], isError: true };
} finally { calls.delete(callKey); }
break;
}
Expand Down
24 changes: 20 additions & 4 deletions plugins/minimax-ai/minimax-bot/miniapp/node/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ import { markdownDocument, HTML_PREVIEW_CSP, MARKDOWN_PREVIEW_CSP } from './docu

export const USER_NAME_MAX = 24;

/** The usage panel's range: a whole number of days, from one day to three months. */
const USAGE_DAYS_DEFAULT = 7;
const USAGE_DAYS_MIN = 1;
const USAGE_DAYS_MAX = 90;

/**
* The account's display name: the macOS full name (`id -F`), otherwise the login name. The Host
* exposes no user identity to Mini Apps, so this is the best default; the user can override it.
Expand Down Expand Up @@ -178,9 +183,20 @@ export async function start(context) {
return { scope, entries, chars: memoryBudget(entries).used, budget: memoryBudget(entries), contextBudget: { limit: MEMORY_CONTEXT_MAX, unit: 'characters' } };
};

const usage = async (days) => {
const hourly = days <= 1;
const span = hourly ? 24 : Math.max(1, Math.min(90, days));
/** The usage panel asks for a number of days; anything but a whole 1..90 is refused. */
const usageDays = (raw) => {
if (raw === undefined || raw === null) return USAGE_DAYS_DEFAULT;
const days = Number(raw);
if (!Number.isInteger(days) || days < USAGE_DAYS_MIN || days > USAGE_DAYS_MAX) {
throw new EngineError('invalid', t('err.usageDays', { min: USAGE_DAYS_MIN, max: USAGE_DAYS_MAX }));
}
return days;
};

const usage = async (raw) => {
const days = usageDays(raw);
const hourly = days === 1;
const span = hourly ? 24 : days;
const pad = (n) => String(n).padStart(2, '0');
const start = new Date();
if (hourly) {
Expand Down Expand Up @@ -658,7 +674,7 @@ export async function start(context) {
}

if (method === 'GET' && path === '/api/usage') {
return json(response, 200, await usage(Number(url.searchParams.get('days') ?? 7)));
return json(response, 200, await usage(url.searchParams.get('days')));
}

json(response, 404, { error: 'not_found', message: t('err.routeNotFound') });
Expand Down
Loading
Loading