From 52a6cbcb25180f74d7d275d0d2fe08e06df5e94f Mon Sep 17 00:00:00 2001 From: coder-bat <43714121+coder-bat@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:20:05 +1100 Subject: [PATCH] fix(minimax-bot): bind a new Bot session to an Agent the host can open A new Bot session bound to the host's primary Agent, because the plugin never named one. The host offers the primary under its canonical name and rejects the legacy `main` alias, so a 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. A Bot team on such a runtime could not talk to its Bots at all. Every turn failed the same way, and the only trace was the generic "The Agent is temporarily unavailable" notice, which reads like a busy host rather than a session that never opened. The turn now names an Agent when the host cannot bind the default one. The Agents the host offers are tried in turn, primary first, and the one that works is used for the sessions that follow, so the probe happens once instead of on every new session. A continued session keeps the Agent it was created with, and a failure that is not a bind failure is never retried with another Agent. When no Agent can be bound, the host's own failure is reported unchanged. The host log now says which Agent was bound, so the fallback is visible next to the turn that used it. --- .../minimax-bot/.minimax-plugin/plugin.json | 2 +- plugins/minimax-ai/minimax-bot/README.md | 3 +- .../minimax-bot/miniapp/node/engine.mjs | 101 +++++++++++- plugins/minimax-ai/minimax-bot/package.json | 2 +- .../minimax-bot/tests/agent-binding.test.mjs | 151 ++++++++++++++++++ 5 files changed, 254 insertions(+), 5 deletions(-) create mode 100644 plugins/minimax-ai/minimax-bot/tests/agent-binding.test.mjs diff --git a/plugins/minimax-ai/minimax-bot/.minimax-plugin/plugin.json b/plugins/minimax-ai/minimax-bot/.minimax-plugin/plugin.json index 1fe4e75b..515b0389 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 c287ee86..d28992f7 100644 --- a/plugins/minimax-ai/minimax-bot/README.md +++ b/plugins/minimax-ai/minimax-bot/README.md @@ -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. @@ -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. diff --git a/plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs b/plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs index ba356355..2c3be8bb 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs +++ b/plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs @@ -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 {{ @@ -132,6 +140,10 @@ export class Engine { this.retryDelays = deps.retryDelaysMs ?? TURN_RETRY_DELAYS_MS; /** @type {{ at: number, value: Promise } | 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} helper id -> run */ this.helperRuns = new Map(); this.helperActive = 0; @@ -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') { @@ -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 + * 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) { + try { + const result = await run(candidate.name); + this.boundAgent = candidate.name; + 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. @@ -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' }); diff --git a/plugins/minimax-ai/minimax-bot/package.json b/plugins/minimax-ai/minimax-bot/package.json index fc67f58d..cc345cb6 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/agent-binding.test.mjs b/plugins/minimax-ai/minimax-bot/tests/agent-binding.test.mjs new file mode 100644 index 00000000..87cc837c --- /dev/null +++ b/plugins/minimax-ai/minimax-bot/tests/agent-binding.test.mjs @@ -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); +});