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
3 changes: 2 additions & 1 deletion plugins/minimax-ai/minimax-bot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ Avatars are stored in the Mini App's `dataDir/avatars/`. `GET /api/avatars` list
## How it runs

- The Node service (`miniapp/node/server.mjs`) uses only Node built-in modules, runs Bot turns through `context.agent`, and stores data in the host-assigned `dataDir`.
- A new Bot session binds to the host's primary Agent. When the host cannot bind that Agent, the Agents the host offers are tried in turn, primary first, and the one that works is used for the sessions that follow. A continued session keeps the Agent it was created with, and the host's own failure is reported unchanged when no Agent can be bound.
- The page (`miniapp/client/`) is build-free native ES modules and loads no remote resources.
- The same Node process serves Streamable HTTP MCP at `/mcp/minimax-bot`, exposing `list_team`, `message_bot`, `dispatch_tasks`, `get_tasks`, `cancel_tasks`, `post_group`, `start_helper`, `list_helpers`, `stop_helper`, `remember`, `forget`, `recall`, `create_bot`, `update_bot`, `delete_bot`, `create_group`, `update_group`, `delete_group`, `create_schedule`, `list_schedules` and `delete_schedule` to Bots.
- `miniapp.json` declares `agentAccess: ["session.run"]`. Tool approvals and questionnaires from Bots are rendered in the page, and the full session can be opened in the MiniMax Code sidebar.
Expand All @@ -89,7 +90,7 @@ Avatars are stored in the Mini App's `dataDir/avatars/`. `GET /api/avatars` list

## Data & access

- **Agent turns**: every Bot reply, scheduled task and background job is an Agent turn run through `context.agent`, with the Agent's full capabilities (files, shell, Skills and MCP, including this plugin's own MCP tools). A tool call that needs approval is shown in the page or in the session's sidebar tab; it runs only after you choose **Allow once**. Nothing is approved automatically.
- **Agent turns**: every Bot reply, scheduled task and background job is an Agent turn run through `context.agent`, with the Agent's full capabilities (files, shell, Skills and MCP, including this plugin's own MCP tools). A tool call that needs approval is shown in the page or in the session's sidebar tab; it runs only after you choose **Allow once**. Nothing is approved automatically. A new session runs as the host's primary Agent unless the host cannot bind it, in which case one of the other Agents the host offers is used.
- **Files written**: only inside the Mini App's `dataDir`: `state.json`, `messages/`, `usage.jsonl`, `memory/` (including its journal, archive and backups), `uploads/` (your attachments) and `avatars/`.
- **Files read outside `dataDir`**:
- `/api/media` serves a local file (up to 1 GiB) only when its absolute path appears in a Bot message of that same chat.
Expand Down
101 changes: 99 additions & 2 deletions plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,14 @@ function errorText(error) {
return String(error);
}

/**
* The Host could not bind a session to an Agent, so the turn never reached the model. A busy
* session is reported as BUSY instead, and a rejected Agent name as INVALID_ARGUMENTS.
*/
function isBindFailure(error) {
return errorCode(error) === 'AGENT_UNAVAILABLE';
}

export class Engine {
/**
* @param {{
Expand All @@ -132,6 +140,10 @@ export class Engine {
this.retryDelays = deps.retryDelaysMs ?? TURN_RETRY_DELAYS_MS;
/** @type {{ at: number, value: Promise<any> } | undefined} */
this.catalogCache = undefined;
/** The Agent the Host could bind a new session to, once the default one proved unbindable. */
this.boundAgent = undefined;
/** @type {Promise<{ name: string, displayName: string, primary: boolean }[]> | undefined} */
this.agentOptions = undefined;
/** @type {Map<string, { controller: AbortController, stoppedBy?: string }>} helper id -> run */
this.helperRuns = new Map();
this.helperActive = 0;
Expand Down Expand Up @@ -790,7 +802,7 @@ export class Engine {
if (signal.aborted || this.disposed) return undefined;
let result;
try {
result = await agent.run({ text, sessionId, ...(model ? { model } : {}) }, { signal, onEvent });
result = await this.runTurn({ text, sessionId, model, signal, onEvent });
} catch (error) {
// The Host rejects a model it no longer offers before the turn starts; say which setting to fix.
if (model && errorCode(error) === 'INVALID_ARGUMENTS') {
Expand All @@ -813,6 +825,91 @@ export class Engine {
return typeof this.agent?.models === 'function';
}

/** True when the Host lets a run name its Agent (`agent.agents()` / `run({ agent })`). */
supportsAgents() {
return typeof this.agent?.agents === 'function';
}

/**
* The Agents the Host offers, primary first.
*
* A new Mini App session binds to the primary Agent unless the caller names one. The Host
* offers the primary under its canonical name and rejects the legacy `main` alias, so a

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you drop the references to host implementation details here and just describe what the plugin does (e.g. "when the default Agent cannot be bound, …")?

* runtime that still stores the primary under that alias cannot bind it: the session never
* opens and every turn fails with AGENT_UNAVAILABLE before the model is reached. Naming an
* Agent the Host can bind keeps the Bot team working there.
* @returns {Promise<{ name: string, displayName: string, primary: boolean }[]>}
*/
async agentCandidates() {
if (!this.supportsAgents()) return [];
if (!this.agentOptions) {
this.agentOptions = Promise.resolve(/** @type {any} */ (this.agent).agents())
.then((value) => (value?.agents ?? [])
.map((entry) => ({
name: String(entry?.name ?? ''),
displayName: String(entry?.displayName ?? ''),
primary: Boolean(entry?.primary),
}))
.filter((entry) => entry.name))
.catch((error) => {
// A failed lookup must not be cached for the rest of the process.
this.agentOptions = undefined;
throw error;
});
}
const agents = await this.agentOptions;
return [...agents].sort((left, right) => Number(right.primary) - Number(left.primary));
}

/**
* Runs a turn, naming the Agent when the Host cannot bind its default one.
*
* A new session binds to the primary Agent. Naming one is only allowed on the first run of a
* session, and a continued session keeps the Agent it was created with. When the default
* Agent cannot be bound, the Agents the Host offers are tried in turn, primary first, and the
* one that works is used for the sessions that follow.
*/
async runTurn({ text, sessionId, model, signal, onEvent }) {
const run = (agent) => /** @type {MiniAppAgentClient} */ (this.agent).run(
{
text,
...(sessionId === undefined ? {} : { sessionId }),
...(agent ? { agent } : {}),
...(model ? { model } : {}),
},
{ signal, onEvent },
);
// A continued session keeps its Agent; naming one again is rejected by the Host.
if (sessionId) return run(undefined);
if (this.boundAgent) {
try {
return await run(this.boundAgent);
} catch (error) {
if (!isBindFailure(error)) throw error;
// The Agent is gone or unbindable now: look for another one.
this.boundAgent = undefined;
}
}
try {
return await run(undefined);
} catch (error) {
if (!isBindFailure(error)) throw error;
const candidates = await this.agentCandidates();
for (const candidate of candidates) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a fallback stays: this tries every Agent in the list, which can include user-created ones. Could we only fall back to the primary Agent, or surface the problem to the user instead?

try {
const result = await run(candidate.name);
this.boundAgent = candidate.name;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Related: whichever Agent gets picked here is then kept for all later new sessions, with only a log line. Another reason to limit this to the primary Agent or tell the user.

this.logger?.warn?.('minimax_bot.agent_bound', { agent: candidate.name, offered: candidates.map((entry) => entry.name) });
return result;
} catch (candidateError) {
if (!isBindFailure(candidateError)) throw candidateError;
}
}
this.logger?.warn?.('minimax_bot.agent_unbound', { offered: candidates.map((entry) => entry.name) });
throw error;
}
}

/**
* The Host's model catalog and default, cached briefly so a busy group round does not ask for
* it on every turn. `undefined` on a Host without per-run models.
Expand Down Expand Up @@ -1522,7 +1619,7 @@ export class Engine {
};
const text = t('helper.brief', { bot: bot?.name ?? 'Bot', role: bot?.role || '-', task: helper.task });
try {
result = await /** @type {any} */ (this.agent).run({ text, ...(model ? { model } : {}) }, { signal: controller.signal, onEvent });
result = await this.runTurn({ text, sessionId: undefined, model, signal: controller.signal, onEvent });
} catch (error) {
if (model && errorCode(error) === 'INVALID_ARGUMENTS') {
throw Object.assign(new Error(errorText(error)), { code: 'MODEL_UNAVAILABLE' });
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",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#49 also bumps to 0.7.8 — whichever merges second should go to 0.7.9.

"private": true,
"type": "module",
"mcode": {
Expand Down
151 changes: 151 additions & 0 deletions plugins/minimax-ai/minimax-bot/tests/agent-binding.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
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';

async function fixture(t) {
const dir = await mkdtemp(join(tmpdir(), 'bot-agent-binding-'));
const store = new Store(dir);
const memory = new MemoryStore(dir);
await store.init();
await memory.init();
setLocale('en');
const warnings = [];
const engine = new Engine({
store,
memory,
logger: { warn(message, detail) { warnings.push({ message, detail }); } },
broadcast() {},
retryDelaysMs: [],
});
t.after(async () => {
engine.dispose();
await store.writeChain;
await rm(dir, { recursive: true, force: true });
});
return { dir, store, memory, engine, warnings };
}

/** The Host could not bind a session to the Agent it was asked for. */
function bindFailure() {
return Object.assign(new Error('Mini App Agent session is unavailable'), { code: 'AGENT_UNAVAILABLE', retryable: false });
}

function turnStarted(options, sessionId) {
options.onEvent({ type: 'turn.started', sessionId, turnId: `turn-${sessionId}` });
return { sessionId, turnId: `turn-${sessionId}` };
}

test('a new session names an Agent the Host can bind when the default one cannot be bound', async (t) => {
const { engine, store, warnings } = await fixture(t);
const bot = { id: 'bot_a', name: 'Alice', role: 'Writer' };
store.state.bots = [bot];
const calls = [];
engine.agent = {
async agents() {
return { agents: [
{ name: 'mavis', displayName: 'Mavis', primary: true },
{ name: 'general', displayName: 'General', primary: false },
] };
},
async run(input, options) {
calls.push(input);
// The Host cannot bind the primary: it stores it under the legacy `main` alias and
// rejects that alias, so the session never opens.
if (!input.agent || input.agent === 'mavis') throw bindFailure();
return { ...turnStarted(options, 'session-general'), status: 'finished', text: 'Done' };
},
};
const result = await engine.runBotTurn({ bot, chatId: `bot:${bot.id}`, text: 'Hello', kind: 'private' });
assert.equal(result.status, 'done');
// The default first, then the offered Agents, primary first.
assert.deepEqual(calls.map((call) => call.agent), [undefined, 'mavis', 'general']);
assert.ok(warnings.some((warning) => warning.message === 'minimax_bot.agent_bound' && warning.detail.agent === 'general'));
});

test('the Agent that worked is used for the next new session without probing again', async (t) => {
const { engine, store } = await fixture(t);
const first = { id: 'bot_a', name: 'Alice', role: 'Writer' };
const second = { id: 'bot_b', name: 'Bob', role: 'Reviewer' };
store.state.bots = [first, second];
const calls = [];
let session = 0;
engine.agent = {
async agents() {
return { agents: [
{ name: 'mavis', displayName: 'Mavis', primary: true },
{ name: 'general', displayName: 'General', primary: false },
] };
},
async run(input, options) {
calls.push(input);
// Only a new session can fail to bind: a continued one keeps the Agent it was created with.
if (!input.sessionId && (!input.agent || input.agent === 'mavis')) throw bindFailure();
session += 1;
return { ...turnStarted(options, `session-${session}`), status: 'finished', text: 'Done' };
},
};
await engine.runBotTurn({ bot: first, chatId: `bot:${first.id}`, text: 'First', kind: 'private' });
await engine.runBotTurn({ bot: second, chatId: `bot:${second.id}`, text: 'Second', kind: 'private' });
// The second Bot opens its own session and goes straight to the Agent that worked.
assert.deepEqual(calls.map((call) => call.agent), [undefined, 'mavis', 'general', 'general']);
});

test('a continued session never names an Agent', async (t) => {
const { engine, store } = await fixture(t);
const bot = { id: 'bot_a', name: 'Alice', role: 'Writer' };
store.state.bots = [bot];
const chatId = `bot:${bot.id}`;
await store.setSession(chatId, bot.id, { sessionId: 'session-1' });
const calls = [];
engine.agent = {
async agents() { return { agents: [{ name: 'mavis', displayName: 'Mavis', primary: true }] }; },
async run(input, options) {
calls.push(input);
return { ...turnStarted(options, 'session-1'), status: 'finished', text: 'Done' };
},
};
const result = await engine.runBotTurn({ bot, chatId, text: 'Continue', kind: 'private' });
assert.equal(result.status, 'done');
assert.equal(calls.length, 1);
assert.equal('agent' in calls[0], false);
});

test('a Host that cannot list Agents keeps the Host failure', async (t) => {
const { engine, store } = await fixture(t);
const bot = { id: 'bot_a', name: 'Alice', role: 'Writer' };
store.state.bots = [bot];
const chatId = `bot:${bot.id}`;
let calls = 0;
engine.agent = {
async run() { calls += 1; throw bindFailure(); },
};
const result = await engine.runBotTurn({ bot, chatId, text: 'Hello', kind: 'private' });
assert.equal(result.status, 'error');
assert.equal(calls, 1);
const messages = await store.getMessages(chatId);
assert.equal(messages.at(-1).errorCode, 'AGENT_UNAVAILABLE');
});

test('a failure that is not a bind failure is never retried with another Agent', async (t) => {
const { engine, store } = await fixture(t);
const bot = { id: 'bot_a', name: 'Alice', role: 'Writer' };
store.state.bots = [bot];
const calls = [];
engine.agent = {
async agents() { return { agents: [{ name: 'general', displayName: 'General', primary: false }] }; },
async run(input) {
calls.push(input);
throw Object.assign(new Error('The Agent turn failed'), { code: 'AGENT_OUTCOME_UNKNOWN' });
},
};
const result = await engine.runBotTurn({ bot, chatId: `bot:${bot.id}`, text: 'Hello', kind: 'private' });
assert.equal(result.status, 'error');
assert.equal(calls.length, 1);
assert.equal(calls[0].agent, undefined);
});