diff --git a/plugins/minimax-ai/minimax-bot/.minimax-plugin/plugin.json b/plugins/minimax-ai/minimax-bot/.minimax-plugin/plugin.json index 515b0389..b213996d 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.8", + "version": "0.7.9", "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 31261d39..c309c0f3 100644 --- a/plugins/minimax-ai/minimax-bot/README.md +++ b/plugins/minimax-ai/minimax-bot/README.md @@ -12,7 +12,7 @@ Copy this directory, including the hidden `.minimax-plugin/` directory, into the - **Long-lived Bots**: every Bot has a name, a role, instructions and its own Agent session. When its setup, the team roster or its memory changes, the next turn automatically carries the latest context. - **Chief Bot**: created on first launch to help you build your team. Chief can create, edit and delete Bots and groups; other Bots are refused when they try to do so. -- **Group chats**: the group admin answers by default. `@Name` calls on a specific member, and a Bot that `@`-mentions another member in its reply hands the turn over. Members with nothing to add reply `[PASS]` and are skipped. Each member keeps a separate session per group, and groups cannot see private chats. There is no fixed reply cap. Discussions finish naturally, or wait for more instructions after repeated handoffs without tool progress. +- **Group chats**: the group admin answers by default. `@Name` calls on a specific member, and a Bot that `@`-mentions another member in its reply hands the turn over. Members with nothing to add reply `[PASS]` and are skipped. Each member keeps a separate session per group, and groups cannot see private chats. There is no fixed reply cap by default; a group can set one in the group settings. Discussions finish naturally, or wait for more instructions after repeated handoffs without tool progress. - **Hand-offs between Bots**: `message_bot` hands work to another Bot and immediately returns an admission receipt; the completed result returns to the source chat. `post_group` is **private chat only**: it posts to a group and returns immediately while the discussion continues according to the group's reply mode. From a private chat, Chief can post to any group without joining it; other Bots can only post to groups they belong to. During a group turn, `post_group` is disabled for everyone, including Chief and admins, for both same-group and cross-group posts. Use `@Name` in a reply to hand off within the group; start cross-group work from a private chat. - **Background work**: when an isolated execution context is useful, a Bot calls `start_helper` to run it in the background in a fresh, separate session; the Bot's turn ends at once and you can keep chatting. The Bot simply says something like "let me look into it" without internal jargon or IDs. When the job finishes, its report comes back to the Bot as a new turn, and the Bot reviews it and passes it on in its own words (in groups too, where it can keep handing off with `@`). At most 2 background jobs run at once (one slot is always kept free for chat) and the rest queue; each Bot can have at most 4 queued or running. Running jobs are shown above the chat input and can be stopped at any time; the strip orders them by how far along they are, the ones already working first, then the ones waiting for another job, then the queued ones; when more than three jobs are listed, it keeps the first three in the open and folds the rest into one line that says how many are left, and one button below the rows opens the whole queue; tool approvals from a background job appear in its chat; jobs still running when the Mini App restarts are marked interrupted. Bots can also manage jobs with `list_helpers` / `stop_helper`. "Allow background helpers automatically" is on by default in Bot settings; when off, a Bot only does so when you explicitly ask. - **Per-Bot model and reasoning effort**: pick a model and reasoning effort in Bot settings (only the levels the model supports are listed, and thinking can be turned off where supported; Bots that follow the default model can still set their own effort). Chief can also set them with the `model` (a model ID, `provider/model` or display name; `default` follows the default) and `effort` (`low`, `high`, `off`, ...) parameters of `create_bot` / `update_bot`. Requires a MiniMax Code version with `agent.models()` / `run({ model })`; older versions prompt you to upgrade. @@ -30,6 +30,14 @@ Copy this directory, including the hidden `.minimax-plugin/` directory, into the - **Reply actions**: the reaction, copy, quote-reply and open-session buttons sit at the bottom-left of a reply, under its last line. A long reply therefore keeps its actions within reach at the end of the text instead of pushing them off the top of the message. The reaction indicator sits directly above that row and is painted with the app's own tag colours, so it follows the light and dark themes. - **Working messages of one reply**: the Host records a text part for every turn a Bot takes between two tool calls, so a reply that worked for a while arrives with many parts, and the page draws one bubble per part. The bubbles keep the official shape and order, and a small gap between them tells them apart instead of letting them read as one block of text. +## 0.7.9 Optional relay guardrails for groups + +- A group can set a hop limit in the group settings: how many Bots may speak in one relay chain. `0` (the default) means no limit, exactly as before; the largest limit is 20. +- When the limit is reached the chain stops there. The group gets a notice saying so, and the Bot that just spoke is asked to summarise instead of calling the next one. The blocked hand-off does not run, but the wrap-up is a turn of its own: a round that reaches the limit runs `limit + 1` Agent turns, the last one being the wrap-up. +- The limit counts every Bot that speaks in the round. In a group where every member replies (`reply_mode: all`), the first replies of the members already in the queue count towards it. +- A group can also hand the previous conclusion over to the next Bot: the next prompt carries the conclusion of the previous reply, so the next Bot does not have to dig the same ground out of the tools again. The previous reply is listed in the new messages anyway — the hand-over repeats its conclusion on purpose. It is prompt text — no extra turn, no extra tool call, no memory write. +- Both settings belong to the group and only you can set them. A Bot cannot change them: the MCP catalogue does not offer them, and a call that smuggles them in is ignored. + ## 0.7.8 Safer automatic retries - A failed turn is still retried automatically at most twice, after 8 s and 20 s. @@ -91,11 +99,11 @@ Avatars are stored in the Mini App's `dataDir/avatars/`. `GET /api/avatars` list ## Tested environment -- MiniMax Code 3.1.2 on macOS and Windows. Earlier versions cannot install this package, because it declares `agentAccess` and `surface.window`. +- MiniMax Code 3.1.2 on macOS and Windows, and MiniMax Code 3.1.4 on Linux. Earlier versions cannot install this package, because it declares `agentAccess` and `surface.window`. ## 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 group with a relay limit spends one more Agent turn on the round that reaches the limit: the wrap-up, which replaces the blocked hand-off. - **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/client/panels.js b/plugins/minimax-ai/minimax-bot/miniapp/client/panels.js index 7b1e4a18..13bd7303 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/client/panels.js +++ b/plugins/minimax-ai/minimax-bot/miniapp/client/panels.js @@ -284,6 +284,44 @@ function groupOverview(group) { ), h('p', { class: 'hint', style: { marginTop: '8px' } }, group.replyMode === 'all' ? t('panels.group.replyAllHint') : t('panels.group.replyAdminHint')), ), + h( + 'section', + null, + h('h3', { class: 'section-title' }, t('panels.group.relayGuard')), + h( + 'label', + { class: 'field' }, + h('span', null, t('panels.group.relayLimit')), + h('input', { + class: 'input', + type: 'number', + name: 'maxHops', + min: 0, + max: 20, + value: Number(group.maxHops ?? 0), + style: { width: '120px' }, + onchange: (event) => { + const raw = Number(event.target.value); + const maxHops = Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 0; + patch({ maxHops }); + }, + }), + ), + h('p', { class: 'hint', style: { marginTop: '8px' } }, t('panels.group.relayLimitHint')), + h( + 'label', + { class: 'check' }, + h('input', { + type: 'checkbox', + class: 'switch', + name: 'handover', + checked: group.handover === true, + onchange: (event) => patch({ handover: event.target.checked === true }), + }), + h('span', null, t('panels.group.handover')), + ), + h('p', { class: 'hint', style: { marginTop: '8px' } }, t('panels.group.handoverHint')), + ), h( 'section', null, diff --git a/plugins/minimax-ai/minimax-bot/miniapp/client/strings-panels.js b/plugins/minimax-ai/minimax-bot/miniapp/client/strings-panels.js index faabdcba..44651135 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/client/strings-panels.js +++ b/plugins/minimax-ai/minimax-bot/miniapp/client/strings-panels.js @@ -175,6 +175,11 @@ export default { 'panels.group.replyAdminHint': '管理员先接话,需要时 @ 其他成员接力。', 'panels.group.replyAdminOption': '管理员(省额度)', 'panels.group.replyAllOption': '所有成员依次回复', + 'panels.group.relayGuard': '接力护栏', + 'panels.group.relayLimit': '接力跳数上限', + 'panels.group.relayLimitHint': '0 表示不限:接力链可以一直跑下去。填上数字后(最多 20),这个群里一轮接力最多几个 Bot 说话;到上限时群里会说明本轮收口。「所有人回复」的群里,这个上限连初始成员一起算。', + 'panels.group.handover': '把上一位的结论交给下一位', + 'panels.group.handoverHint': '下一位的提示词里会带上上一位回复的结论(就是它的尾部),省得下一位再翻一遍工具。上一位的原话本来就在「新消息」里,这里是再强调一遍结论。只多一段提示词,不多花回合。', 'panels.group.settingsKept': '成员设置保留', 'panels.group.needMember': '至少选一个成员', 'panels.group.name': '群名', @@ -526,6 +531,11 @@ export default { 'panels.group.replyAdminHint': 'The admin replies first and @ mentions other members to take over when needed.', 'panels.group.replyAdminOption': 'Admin (saves quota)', 'panels.group.replyAllOption': 'All members reply in turn', + 'panels.group.relayGuard': 'Relay guardrails', + 'panels.group.relayLimit': 'Relay hop limit', + 'panels.group.relayLimitHint': '0 means no limit: the relay chain can keep going. With a number (20 at most), at most that many Bots speak in one chain in this group; when the limit is reached the group says so and the round wraps up. In a group where every member replies, the limit counts those first replies too.', + 'panels.group.handover': 'Hand the previous conclusion to the next Bot', + 'panels.group.handoverHint': "The next Bot's prompt carries the conclusion of the previous reply (its tail), so the next Bot does not have to dig the same ground out of the tools again. The previous reply is in the new messages anyway — this repeats the conclusion on purpose. It is prompt text only — no extra turn.", 'panels.group.settingsKept': 'Member settings are kept', 'panels.group.needMember': 'Pick at least one member', 'panels.group.name': 'Group name', diff --git a/plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs b/plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs index d74983e0..354136ec 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs +++ b/plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs @@ -9,6 +9,7 @@ import { selectMemoryContext } from './memory-context.mjs'; import { UserInbox } from './user-inbox.mjs'; import { TaskDispatcher } from './task-dispatcher.mjs'; import { modelKey, newId, withEffort } from './store.mjs'; +import { CONVERGE_MARK, handoverRequested, handoverSummary, hopLimit, renderHandover } from './relay-guard.mjs'; /** @typedef {import('./miniapp-api.js').MiniAppAgentClient} MiniAppAgentClient */ /** @typedef {import('./miniapp-api.js').MiniAppAgentRunEvent} MiniAppAgentRunEvent */ @@ -252,11 +253,119 @@ export class Engine { return { botLines, groupLines }; } + /** + * The hop limit of a group: how many Bots may speak in one relay chain. 0 = no limit, which + * is the baseline behaviour (the chain runs as long as the Bots hand over to each other). + * + * The limit belongs to the group and only the operator sets it (the panel writes + * `group.maxHops`); the model cannot change it, so a relay chain cannot be extended by the + * Bot that is running it. + * + * @param {any} group + * @returns {number} + */ + maxHops(group) { + return hopLimit(group); + } + + /** + * Whether the conclusion of the previous hop travels with the next hop's prompt. + * @param {any} group + * @returns {boolean} + */ + handoverEnabled(group) { + return handoverRequested(group); + } + + /** + * Whether the group really has guardrails on. The prompt-level rule (one owner per stage) + * follows this one, so a group without guardrails keeps the baseline prompt byte for byte. + * @param {any} group + * @returns {boolean} + */ + relayGuardActive(group) { + return this.maxHops(group) > 0 || this.handoverEnabled(group); + } + + /** + * The conclusion of the previous hop, handed over to the next one. + * + * Free by construction: the summary is text that rides along with the next turn — no model + * turn, no tool call, no memory write. The note the group already produced wins; without a + * note the tail of the previous reply is used. + * + * @param {any} group + * @param {{ botId: string, name: string, text: string, ts: number } | undefined} last + * @returns {{ from: string, text: string } | undefined} + */ + handoverFor(group, last) { + if (!this.handoverEnabled(group)) return undefined; + if (!last?.text) return undefined; + // This build keeps no group notes, so the hand-over carries the tail of the reply. + const text = handoverSummary({ note: '', reply: last.text }); + if (!text) return undefined; + return { from: last.name, text }; + } + + + /** + * The chain reached its hop limit: the round converges instead of relaying on. + * + * Two things happen, in this order. The group is told why the chain stopped — a system notice + * plus an event, so the panel can show it — and then the Bot that just spoke is asked to wrap + * up. The hop that was blocked does not run, but the wrap-up is a model turn of its own: a + * round that reaches the limit therefore runs `limit + 1` model turns, the last one being the + * wrap-up. When it fails the notice stands on its own and the round ends; a group must never + * be left hanging because a model call failed. + * + * @param {any} group + * @param {string} chatId + * @param {{ reason: string, max: number }} info + * @param {{ botId: string, name: string, text: string, ts: number } | undefined} last + * @param {{ sourceMessageId?: string, sourceMessageIds?: string[], taskId?: string, signal?: AbortSignal }} [options] + * @returns {Promise<{ ok: boolean, reason: string }>} + */ + async convergeGroup(group, chatId, info, last, options = {}) { + const notice = await this.store.appendMessage(chatId, { + role: 'system', + key: 'notice.hopLimit', + params: { max: info.max }, + text: t('notice.hopLimit', { max: info.max }), + }); + this.broadcast({ type: 'message.upsert', chatId, message: notice }); + this.broadcastSummary(chatId); + this.broadcast({ + type: 'group.hopLimit', chatId, groupId: group.id, reason: info.reason, max: info.max, hops: info.max, + }); + const bot = last?.botId ? this.store.getBot(last.botId) : undefined; + if (!bot) return { ok: false, reason: 'no_speaker' }; + const result = await this.runBotTurn({ + bot, + chatId, + text: `${CONVERGE_MARK}\n${t('relay.converge', { max: info.max })}`, + kind: 'group', + sourceMessageId: options.sourceMessageId, + sourceMessageIds: options.sourceMessageIds, + signal: options.signal, + // The wrap-up belongs to the same round, so it belongs to the same task. Without it a + // group relay that came in through the task dispatcher would end up outside the task. + taskId: options.taskId, + }); + if (result.status !== 'done') { + this.logger.warn('minimax_bot.relay_converge_failed', { code: errorCode(result) || 'unknown' }); + return { ok: false, reason: 'turn_failed' }; + } + return { ok: true, reason: 'converged' }; + } + /** The Bot's standing context, written in the user's language so replies default to it. */ buildStandingBrief(bot, chatId) { const { botLines, groupLines } = this.roster(); const group = chatId.startsWith('group:') ? this.store.getGroup(chatId.slice(6)) : undefined; const isAdmin = group?.adminId === bot.id; + // F7 · the group has the relay guardrails on: the prompt gets the one-owner rule. With the + // guardrails off this adds nothing, so the prompt stays byte-identical. + const relayGuard = group ? this.relayGuardActive(group) : false; const lines = [ '', t('brief.intro', { name: bot.name, id: bot.id }), @@ -273,6 +382,10 @@ export class Engine { isAdmin ? t('brief.groupAdmin') : t('brief.groupMember'), t('brief.groupPass'), t('brief.groupNoPost'), + // The one-owner rule joins the list only when it is in force. Adding an empty + // string instead would still append a separator, and the English separator is a + // space — the brief would differ from the baseline by one trailing space. + ...(relayGuard ? [t('brief.groupOneOwner')] : []), ].join(t('brief.sentenceJoin')) : t('brief.private'), '', @@ -333,6 +446,7 @@ export class Engine { * bot: any, chatId: string, text: string, kind: string, * sourceMessageId?: string, sourceMessageIds?: string[], taskId?: string, signal?: AbortSignal, * answer?: { sessionId: string, requestId: string, answers: any[] }, + * handover?: { from: string, text: string }, * }} input * @returns {Promise<{ status: string, text: string, messageId: string, sessionId?: string }>} */ @@ -1053,13 +1167,34 @@ export class Engine { let accepted = false; let paused; const seen = new Set(); + // F7 · the hop limit. 0 = no limit = the baseline behaviour: the chain runs as long as the + // Bots hand over to each other. + const limit = this.maxHops(group); + /** How many Bots spoke in this chain so far. */ + let hops = 0; + /** The conclusion of the last hop, handed over to the next one. */ + let lastConclusion; + /** Set when the chain stopped because of the hop limit. */ + let converged; try { while (queue.length && !loop.cancelled && !options.signal?.aborted && !this.disposed) { + // The limit is reached and somebody is still queued: the chain converges instead of + // relaying on. The queue is emptied on purpose — the chain ends here, so nobody is + // left to be called later (the task dispatcher would otherwise resume it). + if (limit > 0 && hops >= limit) { + converged = { reason: 'hop_limit', max: limit }; + queue.length = 0; + break; + } const botId = /** @type {string} */ (queue.shift()); const bot = this.store.getBot(botId); const fresh = this.store.getGroup(group.id); if (!bot || !fresh || !fresh.memberIds.includes(botId)) continue; - const result = await this.runBotTurn({ bot, chatId, text: '', kind: 'group', sourceMessageId, sourceMessageIds, signal: options.signal, taskId: options.taskId }); + // The conclusion of the previous hop rides along with this turn. It is prompt text, not + // a model turn: the hand-over costs nothing. + const handover = this.handoverFor(fresh, lastConclusion); + hops += 1; + const result = await this.runBotTurn({ bot, chatId, text: '', kind: 'group', sourceMessageId, sourceMessageIds, signal: options.signal, taskId: options.taskId, handover }); lastResult = result; accepted ||= result.accepted; if (result.status === 'aborted' && (options.signal?.aborted || loop.cancelled)) queue.unshift(botId); @@ -1082,7 +1217,14 @@ export class Engine { } // Waiting on the user (or stopped): the hand-off resumes from answerQuestion once they reply. if (result.status !== 'done' || result.pendingQuestion) break; + if (result.text) lastConclusion = { botId: bot.id, name: bot.name, text: result.text, ts: Date.now() }; const next = this.mentionsIn(result.text, fresh, bot.id); + // The limit is reached and the reply calls somebody: that Bot is not called, the chain + // converges. This is the whole point of the limit — a chain that never ends. + if (limit > 0 && hops >= limit && next.length) { + converged = { reason: 'hop_limit', max: limit }; + break; + } for (const id of next) if (!queue.includes(id)) queue.push(id); // Exact repeated handoffs without tool progress are a loop, not a turn budget. const signature = JSON.stringify([bot.id, result.text, next]); @@ -1090,6 +1232,12 @@ export class Engine { if (result.toolCount) seen.clear(); else seen.add(signature); } + if (converged) { + // The chain stops here: the group is told why, and the Bot that just spoke wraps up. + await this.convergeGroup(group, chatId, converged, lastConclusion, { + sourceMessageId, sourceMessageIds, signal: options.signal, taskId: options.taskId, + }); + } if (paused) { const note = await this.store.appendMessage(chatId, { role: 'system', key: 'notice.noProgress', text: t('notice.noProgress') }); this.broadcast({ type: 'message.upsert', chatId, message: note }); @@ -1105,8 +1253,12 @@ export class Engine { } } - async groupPrompt(group, bot) { + async groupPrompt(group, bot, { handover } = {}) { const chatId = `group:${group.id}`; + // The conclusion of the previous hop: text that rides along with this turn, no model turn. + const hand = handover ? renderHandover(handover) : ''; + const head = []; + if (hand) head.push(hand, ''); const session = this.store.getSession(chatId, bot.id); // Legacy wall-clock cursors could mark unseen messages as read. Replay the bounded // context once when upgrading a session instead of perpetuating that missing history. @@ -1131,6 +1283,7 @@ export class Engine { return `[${who}] ${clip(m.role === 'user' ? agentTextOf(m.text, m.attachments) : m.text)}`; }); const text = [ + ...head, t('group.newMessages', { name: group.name }), ...(lines.length ? lines : [t('group.noNew')]), '', @@ -1148,7 +1301,7 @@ export class Engine { if (input.answer || !input.chatId.startsWith('group:')) return { ...input, memoryQuery: input.memoryQuery ?? input.text }; const group = this.store.getGroup(input.chatId.slice(6)); if (!group) throw new EngineError('not_found', t('err.groupMissing')); - const snapshot = await this.groupPrompt(group, input.bot); + const snapshot = await this.groupPrompt(group, input.bot, { handover: input.handover }); // Freeze retrieval input from the same snapshot, including relayed new tasks. return { ...input, memoryQuery: input.memoryQuery ?? (input.text || snapshot.memoryQuery), groupCursor: snapshot.groupCursor, text: [snapshot.text, input.text].filter(Boolean).join('\n\n') }; } diff --git a/plugins/minimax-ai/minimax-bot/miniapp/node/mcp.mjs b/plugins/minimax-ai/minimax-bot/miniapp/node/mcp.mjs index 15988977..ecd0b927 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/node/mcp.mjs +++ b/plugins/minimax-ai/minimax-bot/miniapp/node/mcp.mjs @@ -586,7 +586,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.8' }, + serverInfo: { name: 'minimax-bot', version: '0.7.9' }, instructions: t('mcp.instructions'), }; break; diff --git a/plugins/minimax-ai/minimax-bot/miniapp/node/relay-guard.mjs b/plugins/minimax-ai/minimax-bot/miniapp/node/relay-guard.mjs new file mode 100644 index 00000000..2e0ee555 --- /dev/null +++ b/plugins/minimax-ai/minimax-bot/miniapp/node/relay-guard.mjs @@ -0,0 +1,144 @@ +// @ts-check +// Relay guardrails for group chats (F7). +// +// Two capabilities, both per group and both off by default: +// +// - the hop limit (`group.maxHops`): how many Bots may speak in one relay chain. +// 0 means "no limit", which is exactly the baseline behaviour — the chain runs as long as +// the Bots keep handing over to each other. When the limit is reached the chain converges: +// the Bot that was about to be called is not called, and the Bot that just spoke is asked +// to wrap up instead. +// +// - the summary hand-over (`group.handover`): the conclusion of the previous hop travels with +// the next hop's prompt ("the previous one said: …"), so the next Bot does not have to dig +// the same ground out of the tools again. +// +// The hand-over is free by construction: it is text that rides along with the next turn. No +// extra model turn, no extra tool call, no extra memory write. That is what makes it cheaper +// than a discussion minute — a minute costs one model turn per round, the hand-over costs +// nothing at all. +// +// Everything in this module is pure: the engine decides *when* a hand-over happens, this +// module only says *what* it says. That keeps the rules testable without a model. + +import { t } from './i18n.mjs'; + +/** Opens the block that carries the previous hop's conclusion. */ +export const HANDOVER_BLOCK_MARK = ''; +/** Closes the block that carries the previous hop's conclusion. */ +export const HANDOVER_BLOCK_END = ''; +/** Marks the prompt that asks the current speaker to wrap up. */ +export const CONVERGE_MARK = ''; +/** Longest summary handed over to the next hop. */ +export const HANDOVER_MAX = 600; +/** How much of the previous reply's tail is kept when the group keeps no note. */ +export const HANDOVER_TAIL = 400; + +/** + * Protocol marks a reply must never be able to forge: a Bot that writes them into its answer + * would otherwise be able to open a block of its own in the next Bot's prompt. + */ +const FORGED_MARKS = [ + HANDOVER_BLOCK_MARK, + HANDOVER_BLOCK_END, + CONVERGE_MARK, + '', + '', + '', +]; + +/** The largest hop limit a group can hold. The panel offers the same range. */ +export const HOP_LIMIT_MAX = 20; + +/** + * The hop limit of a group: how many Bots may speak in one relay chain. 0 = no limit. + * + * A broken value reads as "no limit", so a hand-edited state file can never silence a group. + * Only a number (or a string of digits) is a limit: `true`, `null` and `'abc'` are not. + * A limit above {@link HOP_LIMIT_MAX} is clamped, so the same bound holds whether the value + * came from the panel or from a direct call to the group API. + * @param {any} group + * @returns {number} + */ +export function hopLimit(group) { + const value = group?.maxHops; + if (typeof value === 'number') { + if (!Number.isFinite(value) || value <= 0) return 0; + return Math.min(Math.floor(value), HOP_LIMIT_MAX); + } + if (typeof value === 'string' && /^\d+$/.test(value.trim())) { + const raw = Number(value.trim()); + return raw > 0 ? Math.min(raw, HOP_LIMIT_MAX) : 0; + } + return 0; +} + +/** + * Whether the group wants the conclusion of the previous hop handed over. + * @param {any} group + * @returns {boolean} + */ +export function handoverRequested(group) { + return group?.handover === true; +} + +/** + * Removes the protocol marks from a text, so a reply can never pretend to open or close a + * block of its own. + * @param {string} text + * @returns {string} + */ +export function neutralizeMarks(text) { + let out = String(text ?? ''); + for (const mark of FORGED_MARKS) out = out.split(mark).join(' '); + return out; +} + +/** + * The tail of a reply: a conclusion sits at the end of an answer, not at its beginning. + * + * @param {string} text + * @param {number} [max] + * @returns {string} + */ +export function tailSummary(text, max = HANDOVER_TAIL) { + const clean = neutralizeMarks(String(text ?? '')).replace(/\s+/g, ' ').trim(); + if (!clean) return ''; + if (clean.length <= max) return clean; + return clean.slice(-max); +} + +/** + * The summary of the previous hop. + * + * The note the group already produced wins: it is the structured conclusion of the round and it + * cost nothing extra to reuse. Without a note the tail of the previous reply is used, which + * costs nothing either. + * + * @param {{ note?: string, reply?: string, max?: number }} input + * @returns {string} + */ +export function handoverSummary({ note, reply, max = HANDOVER_MAX } = {}) { + const fromNote = neutralizeMarks(String(note ?? '')).replace(/\s+/g, ' ').trim(); + if (fromNote) return fromNote.length <= max ? fromNote : fromNote.slice(0, max); + const tail = tailSummary(reply, Math.min(max, HANDOVER_TAIL)); + return tail; +} + +/** + * The block that travels with the next hop's prompt. An empty string when there is nothing to + * hand over, so the prompt of a group without the hand-over is byte-for-byte what it was. + * + * @param {{ from?: string, text?: string }} input + * @returns {string} + */ +export function renderHandover({ from, text } = {}) { + const body = String(text ?? '').trim(); + if (!body) return ''; + const who = String(from ?? '').trim(); + return [ + HANDOVER_BLOCK_MARK, + t('relay.handover', { from: who, text: body }), + HANDOVER_BLOCK_END, + ].join('\n'); +} diff --git a/plugins/minimax-ai/minimax-bot/miniapp/node/strings.mjs b/plugins/minimax-ai/minimax-bot/miniapp/node/strings.mjs index c2f2addf..aeeb1be3 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/node/strings.mjs +++ b/plugins/minimax-ai/minimax-bot/miniapp/node/strings.mjs @@ -34,6 +34,7 @@ export default { 'greeting.chief': '你好!我是 Chief,从现在起负责管理你的 Bot 团队。需要加新 Bot、拉群协作或者派活,直接跟我说就行。\n\n先问一句:你希望这个团队先帮你做什么?', 'notice.groupCreated': '群聊已创建。管理员 {admin} 默认接话;用 @名字 可以直接点名某个 Bot。', "notice.noProgress": "检测到重复交接且没有新的工具进展,讨论已暂停。可以补充要求后继续;独立任务仍会运行。", + "notice.hopLimit": "已达接力上限({max} 跳),本轮讨论在此收口。", 'brief.tool.tasks': "- dispatch_tasks:一次交给多个 Bot,用于需要多人协作的同一请求。和 message_bot 一样持久受理,完成后结果回到发起聊天;不需要预先指定结果群或汇总人。get_tasks 可查看进展,cancel_tasks 明确停止。不要向用户暴露任务编号或要求用户选择执行模式。外部主会话没有回调能力,须主动查询结果。", 'mcp.t.dispatch_tasks': "持久化派发一批任务并立即返回 batch_id/task_id,不按任务长短区分路径。不同 Bot 可并行,同一私聊串行,共用 Host 并发额度。request_id 必填,相同内容重试返回原任务;不同内容报冲突。结果自动回到发起聊天,由发起 Bot 验收,只回传本次产出。外部主会话没有回调能力,应 get_tasks 查询。", "mcp.t.get_tasks": "用 batch_id 或 task_id 查询任务(只填一个),wait_ms 最多 20000,只短等状态变化,不取消任务。状态包括 queued/running/waiting_for_tasks/waiting_for_user/paused/done/failed/cancelled/unknown;unknown 不代表失败,不要自动重派。单任务结果按 result_offset/next_result_offset 分片读取。", @@ -219,6 +220,8 @@ export default { 'group.user': '用户', 'group.noNew': '(没有新消息)', 'group.yourTurn': '现在轮到你({name})发言。没有新内容要补充就只回复 [PASS]。', + 'relay.converge': '已达接力上限({max} 跳),请就上面的讨论给出收口总结,不要再 @ 别人。', + 'relay.handover': '上一位({from})说:{text}', 'relay.fromUserSession': '用户的 MiniMax Code 会话', 'relay.prompt': '[来自「{from}」的消息。请处理后直接回复,你的回复会作为结果交还给对方。]', 'schedule.trigger': '[定时任务「{name}」自动触发]', @@ -237,6 +240,7 @@ export default { 'brief.groupAdmin': '你是这个群的管理员:用户没有 @ 任何人时由你回复;需要别的成员出力时,在回复里写 @名字 点名,对方会接着发言。', 'brief.groupMember': '可以在回复里写 @名字 把话题交给其他成员。', 'brief.groupPass': '如果你没有新的内容要补充,只回复 [PASS]。群聊里看不到你和用户的私聊内容。', + 'brief.groupOneOwner': '每个阶段只指定一个负责人(one owner at each stage);需要别人接手时,用 @ 点名并附上你的结论摘要。', 'brief.groupNoPost': 'post_group 在群聊中禁用,Chief 和管理员也不能调用,不能向本群或其他群发起投递。请直接在回复里 @名字 让本群成员接力;跨群任务请用户回到单聊发起。', 'brief.sentenceJoin': '', 'brief.private': '这是你和用户的私聊。', @@ -346,6 +350,7 @@ export default { 'greeting.chief': "Hi! I'm Chief, and I'll manage your Bot team from here on. Just tell me when you want to add a Bot, start a group or hand out work.\n\nFirst question: what would you like this team to help you with?", 'notice.groupCreated': 'Group created. The admin {admin} replies by default; use @name to call on a specific Bot.', "notice.noProgress": "Repeated handoffs without new tool progress detected. Discussion paused; add instructions to continue. Independent tasks keep running.", + "notice.hopLimit": "The relay limit ({max} hops) is reached; this round wraps up here.", 'brief.tool.tasks': "- dispatch_tasks: hand work to several Bots in one request. Like message_bot, admission is durable and results return to the source chat. Do not preconfigure result destinations or summarizers. get_tasks checks progress; cancel_tasks explicitly stops work. Do not expose task IDs or ask the user to select execution modes. External host chats have no callback and must query results.", 'mcp.t.dispatch_tasks': "Persist a batch of tasks and immediately return batch_id/task_id, without classifying work as short or long. Different Bots can run in parallel; each private chat is serial and shares Host capacity. Required request_id deduplicates identical retries; changed content conflicts. Task outputs return to the source chat for verification by the caller Bot. External host chats must query get_tasks; no callback is available.", "mcp.t.get_tasks": "Query exactly one of batch_id or task_id. wait_ms is bounded to 20000 and only waits for a state change; it never cancels work. States: queued/running/waiting_for_tasks/waiting_for_user/paused/done/failed/cancelled/unknown. Unknown is not a confirmed failure: do not automatically redispatch. Read full single-task outputs with result_offset/next_result_offset.", @@ -524,6 +529,8 @@ export default { 'group.user': 'User', 'group.noNew': '(no new messages)', 'group.yourTurn': "It's your turn ({name}). If you have nothing new to add, reply with just [PASS].", + "relay.converge": "The relay limit ({max} hops) is reached. Summarise the discussion above; do not @ anyone else.", + "relay.handover": "The previous speaker ({from}) said: {text}", 'relay.fromUserSession': "the user's MiniMax Code chat", 'relay.prompt': '[Message from "{from}". Handle it and reply directly; your reply is handed back to them as the result.]', 'schedule.trigger': '[Scheduled task "{name}" triggered automatically]', @@ -542,6 +549,7 @@ export default { 'brief.groupAdmin': 'You are the admin of this group: you reply when the user does not @mention anyone; when another member should help, write @name in your reply and they will speak next.', 'brief.groupMember': 'You can write @name in your reply to hand the topic to another member.', 'brief.groupPass': "If you have nothing new to add, reply with just [PASS]. The group can't see your private chat with the user.", + 'brief.groupOneOwner': "Name one owner per stage; when someone else has to take over, @ them and attach your conclusion.", 'brief.groupNoPost': 'post_group is disabled in group chats, including for Chief and admins. Do not post to this or another group. @name a member in your reply to hand off within this group; ask the user to start cross-group work from a private chat.', 'brief.sentenceJoin': ' ', 'brief.private': 'This is your private chat with the user.', diff --git a/plugins/minimax-ai/minimax-bot/miniapp/node/team.mjs b/plugins/minimax-ai/minimax-bot/miniapp/node/team.mjs index 5493f08d..8a62ff01 100644 --- a/plugins/minimax-ai/minimax-bot/miniapp/node/team.mjs +++ b/plugins/minimax-ai/minimax-bot/miniapp/node/team.mjs @@ -4,6 +4,7 @@ import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/p import { isAbsolute, join } from 'node:path'; import { BOT_SKINS, SKIN_INK, effortOf, newId, normalizeEffort, normalizeFace, normalizeModel, normalizeSkin, skinForColor } from './store.mjs'; import { EngineError, nextRun } from './engine.mjs'; +import { hopLimit } from './relay-guard.mjs'; import { MemoryError, TOPIC_KEY_RE, storageBudget } from './memory.mjs'; import { MEMORY_CONTEXT_MAX } from './memory-context.mjs'; import { isDefaultText, resolveLocale, setLocale, t } from './i18n.mjs'; @@ -330,6 +331,11 @@ export class Team { replyMode: input.replyMode === 'all' ? 'all' : 'admin', pinned: false, createdAt: Date.now(), + // F7 · the relay guardrails. The hop limit counts how many Bots may speak in one relay + // chain; 0 means "no limit", which is the baseline behaviour. The hand-over carries the + // conclusion of the previous hop into the next one. Both are off unless the group asks. + maxHops: hopLimit({ maxHops: input.maxHops }), + handover: input.handover === true, }; this.store.state.groups.push(group); this.store.bumpRoster(); @@ -360,6 +366,9 @@ export class Team { if (patch.notice !== undefined) draft.notice = String(patch.notice).trim().slice(0, 500); if (patch.replyMode !== undefined) draft.replyMode = patch.replyMode === 'all' ? 'all' : 'admin'; if (patch.pinned !== undefined) draft.pinned = Boolean(patch.pinned); + // The relay guardrails: absent leaves the group's own setting alone. 0 = no limit. + if (patch.maxHops !== undefined) draft.maxHops = hopLimit({ maxHops: patch.maxHops }); + if (patch.handover !== undefined) draft.handover = Boolean(patch.handover); if (patch.members !== undefined) { const members = this.memberList(patch.members); if (members.length === 0) throw new EngineError('invalid', t('err.groupNeedsMember')); diff --git a/plugins/minimax-ai/minimax-bot/package.json b/plugins/minimax-ai/minimax-bot/package.json index cc345cb6..630a6dda 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.8", + "version": "0.7.9", "private": true, "type": "module", "mcode": { diff --git a/plugins/minimax-ai/minimax-bot/tests/relay-guard.test.mjs b/plugins/minimax-ai/minimax-bot/tests/relay-guard.test.mjs new file mode 100644 index 00000000..627ee645 --- /dev/null +++ b/plugins/minimax-ai/minimax-bot/tests/relay-guard.test.mjs @@ -0,0 +1,385 @@ +// @ts-check +// The relay guardrails of a group, written as executable assertions. +// +// The point of this file: the claims the guardrails make are not left to a code review. +// +// 1. the hop limit is a single knob on the group: 0 = no limit = the baseline behaviour; +// 2. a broken value reads as "no limit" — a hand-edited state file can never silence a group; +// 3. the hand-over is free: it is prompt text, never an extra model turn; +// 4. without a limit the group behaves exactly like the baseline, prompt byte for byte; +// 5. a reply can never forge the hand-over block or the wrap-up prompt; +// 6. the model cannot change either setting: only the operator can. + +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 { + CONVERGE_MARK, HANDOVER_BLOCK_END, HANDOVER_BLOCK_MARK, HANDOVER_MAX, HOP_LIMIT_MAX, + handoverRequested, handoverSummary, hopLimit, neutralizeMarks, renderHandover, tailSummary, +} from '../miniapp/node/relay-guard.mjs'; +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 { createMcpHandler } from '../miniapp/node/mcp.mjs'; +import { setLocale, t as translate } from '../miniapp/node/i18n.mjs'; + +async function until(predicate) { + for (let i = 0; i < 400; i += 1) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error('The expected engine state did not arrive'); +} + +async function fixture(t, locale = 'en') { + const dir = await mkdtemp(join(tmpdir(), 'bot-relay-guard-')); + const store = new Store(dir); + const memory = new MemoryStore(dir); + await store.init(); + await memory.init(); + setLocale(locale); + const events = []; + const engine = new Engine({ + store, memory, logger: { warn() {} }, broadcast: (event) => events.push(event), retryDelaysMs: [], + }); + const team = new Team({ store, memory, engine, broadcast() {} }); + const handle = createMcpHandler({ team, engine }); + const rpc = async (name, args) => { + let response; + await handle({ method: 'POST' }, { writeHead() { return this; }, end(body) { response = JSON.parse(body); } }, + { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: args } }); + return response.result; + }; + t.after(async () => { + engine.dispose(); + await until(() => engine.running.size === 0 && engine.helperRuns.size === 0 && engine.inbox.batches.size === 0 + && engine.tasks.runs.size === 0 && engine.tasks.deliveries.size === 0); + await store.writeChain; + await rm(dir, { recursive: true, force: true }); + }); + return { dir, store, memory, engine, team, rpc, events }; +} + +function started(input, options, sessionId) { + const session = input.sessionId ?? sessionId; + options.onEvent({ type: 'turn.started', sessionId: session, turnId: `turn-${session}` }); + return { sessionId: session, turnId: `turn-${session}` }; +} + +function idle(engine) { + return engine.running.size === 0 && engine.tickets.size === 0 && engine.groupLoops.size === 0 + && engine.inbox.batches.size === 0 && engine.tasks.runs.size === 0 && engine.tasks.deliveries.size === 0; +} + +// ---------------------------------------------------------------- 1. the hop limit is one knob + +test('the hop limit is a single knob on the group: 0 means no limit', () => { + assert.equal(hopLimit({ maxHops: 0 }), 0); + assert.equal(hopLimit({ maxHops: 2 }), 2); + assert.equal(hopLimit({}), 0, 'a group without the key reads as "no limit"'); + assert.equal(hopLimit(undefined), 0); +}); + +test('a broken hop limit reads as "no limit" instead of silencing the group', () => { + for (const value of [-1, 'abc', null, undefined, {}, NaN, true]) { + assert.equal(hopLimit({ maxHops: value }), 0, `maxHops=${String(value)}`); + } + // A fractional limit is floored: 2.9 hops is two hops. + assert.equal(hopLimit({ maxHops: 2.9 }), 2); +}); + +test('a hop limit above the panel range is clamped', () => { + assert.equal(HOP_LIMIT_MAX, 20); + assert.equal(hopLimit({ maxHops: 20 }), 20); + assert.equal(hopLimit({ maxHops: 21 }), 20); + assert.equal(hopLimit({ maxHops: 500 }), 20); + assert.equal(hopLimit({ maxHops: '500' }), 20); +}); + +test('the hand-over has its own switch and is off unless the group asks for it', () => { + assert.equal(handoverRequested({ handover: true }), true); + assert.equal(handoverRequested({ handover: false }), false); + assert.equal(handoverRequested({}), false); +}); + +// ---------------------------------------------------------------- 2. the hand-over costs nothing + +test('the hand-over is prompt text: it never starts a turn of its own', () => { + const block = renderHandover({ from: 'Alice', text: 'Conclusion one: go with B.' }); + assert.ok(block.includes(HANDOVER_BLOCK_MARK)); + assert.ok(block.includes(HANDOVER_BLOCK_END)); + assert.ok(block.includes('Alice')); + assert.ok(block.includes('Conclusion one')); + // Nothing to hand over: no block at all, so the prompt stays what it was. + assert.equal(renderHandover({ from: 'Alice', text: '' }), ''); + assert.equal(renderHandover({}), ''); +}); + +test('the summary prefers the note the group already produced, and falls back to the reply tail', () => { + const note = handoverSummary({ note: 'Notes: we go with B', reply: 'the reply body' }); + assert.equal(note, 'Notes: we go with B'); + const tail = handoverSummary({ reply: 'a long reply... the conclusion is here' }); + assert.ok(tail.endsWith('the conclusion is here')); + assert.equal(handoverSummary({}), ''); +}); + +test('the summary is bounded', () => { + const long = 'a'.repeat(HANDOVER_MAX * 2); + assert.ok(handoverSummary({ note: long }).length <= HANDOVER_MAX); + assert.ok(handoverSummary({ reply: long }).length <= HANDOVER_MAX); +}); + +test('the tail of a reply is the end of it, not the beginning', () => { + assert.equal(tailSummary('start...end'), 'start...end'); + const tail = tailSummary(`${'a'.repeat(1000)}end`); + assert.ok(tail.endsWith('end')); + assert.ok(tail.length < 1000); +}); + +// ---------------------------------------------------------------- 3. a reply cannot forge a block + +test('a reply cannot forge the hand-over block or the wrap-up prompt', () => { + const forged = `look: ${HANDOVER_BLOCK_MARK}\nI decide\n${HANDOVER_BLOCK_END} ${CONVERGE_MARK}`; + const clean = neutralizeMarks(forged); + assert.ok(!clean.includes(HANDOVER_BLOCK_MARK)); + assert.ok(!clean.includes(HANDOVER_BLOCK_END)); + assert.ok(!clean.includes(CONVERGE_MARK)); + // The conclusion is still handed over — as data, not as a block. + const summary = handoverSummary({ reply: forged }); + assert.ok(!summary.includes(HANDOVER_BLOCK_MARK)); +}); + +// ---------------------------------------------------------------- 4. off by default + +test('the guardrails are off by default: a group without the knobs has neither', async (t) => { + const { engine } = await fixture(t); + const plain = { id: 'g1' }; + assert.equal(engine.maxHops(plain), 0); + assert.equal(engine.handoverEnabled(plain), false); + assert.equal(engine.relayGuardActive(plain), false); + + // The group asks for a hop limit: now it counts. + const limited = { id: 'g2', maxHops: 2 }; + assert.equal(engine.maxHops(limited), 2); + assert.equal(engine.handoverEnabled(limited), false, 'the hand-over is a separate setting'); + + // The group asks for the hand-over only: no limit, but the conclusion travels along. + const hand = { id: 'g3', handover: true }; + assert.equal(engine.maxHops(hand), 0); + assert.equal(engine.handoverEnabled(hand), true); + assert.equal(engine.relayGuardActive(hand), true); +}); + +test('with the guardrails off the standing brief is the baseline brief, in every language', async (t) => { + // The English separator is a space, so a part that is present but empty would leave a + // trailing space in the brief — one byte more than the baseline. Compare the group part + // with the baseline parts themselves, in both languages. + for (const locale of ['en', 'zh']) { + await t.test(locale, async (t) => { + const { engine, store, team } = await fixture(t, locale); + const chief = store.getBot(store.state.bots[0].id); + const group = await team.createGroup(chief, { name: 'Group', members: [chief.id], admin: chief.id }); + const brief = engine.buildStandingBrief(chief, `group:${group.id}`); + // The one-owner rule is not in the brief at all. + assert.ok(!brief.includes('one owner at each stage')); + assert.ok(!brief.includes('一个负责人')); + // The group part is exactly the baseline parts, joined by the language's separator. + const baseline = [ + translate('brief.inGroup', { name: group.name, notice: translate('brief.none') }), + translate('brief.groupAdmin'), + translate('brief.groupPass'), + translate('brief.groupNoPost'), + ].join(translate('brief.sentenceJoin')); + assert.ok(brief.includes(baseline), 'the group part is the baseline parts, joined'); + // And the brief does not end on a separator. + assert.ok(!/\s$/.test(brief), `the brief ends with ${JSON.stringify(brief.slice(-3))}`); + }); + } +}); + +test('the wrap-up turn belongs to the task the relay came in with', async (t) => { + const { engine, store, team } = await fixture(t); + const chief = store.getBot(store.state.bots[0].id); + const alice = await team.createBot(chief, { name: 'Alice', role: 'Writer', instructions: 'Drafts.' }); + const group = await team.createGroup(chief, { name: 'Relay group', members: [alice.id], admin: alice.id, maxHops: 2 }); + engine.agent = { async run(input, options) { + const ids = started(input, options, 'session-alice'); + return { ...ids, status: 'finished', text: 'Wrap-up: the plan stands.' }; + } }; + await engine.convergeGroup(group, `group:${group.id}`, { reason: 'hop_limit', max: 2 }, { + botId: alice.id, name: alice.name, text: 'First: take B. @Bob check it.', ts: Date.now(), + }, { taskId: 'task_abc' }); + const messages = await store.getMessages(`group:${group.id}`); + const wrapUp = messages.find((message) => message.role === 'bot' && message.status === 'done'); + assert.ok(wrapUp, 'the wrap-up spoke'); + assert.equal(wrapUp.taskId, 'task_abc', 'the wrap-up is filed under the task it belongs to'); +}); + +test('without a limit the standing brief is exactly what it was', async (t) => { + const { engine, store, team } = await fixture(t); + const chief = store.getBot(store.state.bots[0].id); + const group = await team.createGroup(chief, { name: 'Group', members: [chief.id], admin: chief.id }); + const plain = engine.buildStandingBrief(chief, `group:${group.id}`); + // Now the group asks for a hop limit: the prompt gains the one-owner rule. + group.maxHops = 2; + const guarded = engine.buildStandingBrief(chief, `group:${group.id}`); + assert.notEqual(guarded, plain, 'a group with a hop limit gains the one-owner rule'); + assert.match(guarded, /one owner/); + // Back to no limit: the brief is byte for byte what it was. + group.maxHops = 0; + assert.equal(engine.buildStandingBrief(chief, `group:${group.id}`), plain); +}); + +// ---------------------------------------------------------------- 5. the group keeps the settings + +test('a group keeps its hop limit and its hand-over switch', async (t) => { + const { team, store } = await fixture(t); + const chief = store.getBot(store.state.bots[0].id); + const alice = await team.createBot(chief, { name: 'Alice', role: 'Writer', instructions: 'Drafts.' }); + const group = await team.createGroup(chief, { + name: 'Relay group', members: [alice.id], admin: alice.id, maxHops: 3, handover: true, + }); + assert.equal(group.maxHops, 3); + assert.equal(group.handover, true); + // A group created without the knobs reads as "no limit, no hand-over". + const plain = await team.createGroup(chief, { name: 'Plain group', members: [alice.id], admin: alice.id }); + assert.equal(plain.maxHops, 0); + assert.equal(plain.handover, false); + // Both can be changed afterwards. + const updated = await team.updateGroup(chief, plain.id, { maxHops: 2 }); + assert.equal(updated.maxHops, 2); + const broken = await team.updateGroup(chief, plain.id, { maxHops: 'abc' }); + assert.equal(broken.maxHops, 0, 'a broken limit reads as "no limit"; it must never silence the group'); +}); + +test('the model cannot change the relay settings: the catalogue does not offer them', async (t) => { + const { team, engine, store, rpc } = await fixture(t); + const chief = store.getBot(store.state.bots[0].id); + const alice = await team.createBot(chief, { name: 'Alice', role: 'Writer', instructions: 'Drafts.' }); + const group = await team.createGroup(chief, { name: 'Relay group', members: [alice.id], admin: alice.id }); + let catalog; + const handle = createMcpHandler({ team, engine }); + await handle({ method: 'POST' }, { writeHead() { return this; }, end(body) { catalog = JSON.parse(body).result; } }, + { jsonrpc: '2.0', id: 1, method: 'tools/list' }); + const updateGroup = catalog.tools.find((tool) => tool.name === 'update_group'); + const properties = Object.keys(updateGroup.inputSchema.properties); + assert.ok(!properties.includes('max_hops'), 'the hop limit is not a model-facing parameter'); + assert.ok(!properties.includes('handover'), 'the hand-over is not a model-facing parameter'); + // Even a call that smuggles them in changes nothing. + await rpc('update_group', { caller_bot_id: chief.id, group: group.id, max_hops: 2, handover: true }); + const stored = store.getGroup(group.id); + assert.equal(stored.maxHops, 0); + assert.equal(stored.handover, false); +}); + +// ---------------------------------------------------------------- 6. the limit stops the chain + +/** + * A scripted chain: Alice hands to Bob, Bob hands back to Alice, and so on. + * + * The speaker is read from the turn marker, not from the prompt as a whole: the prompt + * carries the transcript, so the transcript mentions both names. + */ +function relayScript() { + const counts = new Map(); + return (input) => { + if (input.text.includes(CONVERGE_MARK)) return 'Wrap-up: the plan stands.'; + const who = input.text.includes("It's your turn (Alice).") ? 'Alice' : 'Bob'; + const n = (counts.get(who) ?? 0) + 1; + counts.set(who, n); + if (who === 'Alice') return n === 1 ? 'First: take B. @Bob check it.' : 'Third: cut the budget. @Bob wrap up.'; + return n === 1 ? 'Second: B misses a budget. @Alice decide.' : 'Fourth: wrapped up.'; + }; +} + +test('a hop limit stops the chain at the limit and the round wraps up', async (t) => { + const { engine, store, team } = await fixture(t); + const chief = store.getBot(store.state.bots[0].id); + const alice = await team.createBot(chief, { name: 'Alice', role: 'Writer', instructions: 'Drafts.' }); + const bob = await team.createBot(chief, { name: 'Bob', role: 'Reviewer', instructions: 'Reviews.' }); + const group = await team.createGroup(chief, { + name: 'Relay group', members: [alice.id, bob.id], admin: alice.id, maxHops: 2, + }); + const prompts = []; + const script = relayScript(); + engine.agent = { async run(input, options) { + const speaker = input.text.includes("It's your turn (Alice).") ? alice : bob; + const ids = started(input, options, `session-${speaker.id}`); + prompts.push(input.text); + return { ...ids, status: 'finished', text: script(input) }; + } }; + await engine.sendUserMessage(`group:${group.id}`, '@Alice what do you think?', [], 'relay-limit'); + await until(() => idle(engine)); + const messages = await store.getMessages(`group:${group.id}`); + const notice = messages.find((message) => message.role === 'system' && message.key === 'notice.hopLimit'); + assert.ok(notice, 'the group is told that the chain stopped'); + assert.ok(notice.text.includes('2'), 'the notice says which limit was reached'); + const spoke = messages + .filter((message) => message.role === 'bot' && message.status === 'done') + .filter((message) => !notice || message.seq < notice.seq) + .map((message) => store.getBot(message.botId)?.name); + assert.deepEqual(spoke, ['Alice', 'Bob'], 'only two Bots spoke: the chain stopped at the limit'); + // The wrap-up is one turn by the Bot that just spoke. + const wrapUp = messages.filter((message) => message.role === 'bot' && message.status === 'done' && notice && message.seq > notice.seq); + assert.equal(wrapUp.length, 1); + assert.equal(store.getBot(wrapUp[0].botId)?.name, 'Bob'); + assert.ok(prompts.some((text) => text.includes(CONVERGE_MARK)), 'the wrap-up prompt is the converge one'); +}); + +test('a failing wrap-up does not break the chain: the limit still holds, the notice still stands', async (t) => { + const { engine, store, team } = await fixture(t); + const chief = store.getBot(store.state.bots[0].id); + const alice = await team.createBot(chief, { name: 'Alice', role: 'Writer', instructions: 'Drafts.' }); + const bob = await team.createBot(chief, { name: 'Bob', role: 'Reviewer', instructions: 'Reviews.' }); + const group = await team.createGroup(chief, { + name: 'Relay group', members: [alice.id, bob.id], admin: alice.id, maxHops: 2, + }); + const script = relayScript(); + engine.agent = { async run(input, options) { + if (input.text.includes(CONVERGE_MARK)) throw new Error('the wrap-up turn failed'); + const speaker = input.text.includes("It's your turn (Alice).") ? alice : bob; + const ids = started(input, options, `session-${speaker.id}`); + return { ...ids, status: 'finished', text: script(input) }; + } }; + await engine.sendUserMessage(`group:${group.id}`, '@Alice what do you think?', [], 'relay-fail'); + await until(() => idle(engine)); + const messages = await store.getMessages(`group:${group.id}`); + const notice = messages.find((message) => message.role === 'system' && message.key === 'notice.hopLimit'); + assert.ok(notice, 'the group still learns why the chain stopped'); + const spoke = messages + .filter((message) => message.role === 'bot' && message.status === 'done') + .filter((message) => !notice || message.seq < notice.seq) + .map((message) => store.getBot(message.botId)?.name); + assert.deepEqual(spoke, ['Alice', 'Bob'], 'the chain still stops at the limit'); + assert.equal(messages.filter((message) => message.status === 'error').length, 1, + 'the failure is reported in the chat instead of being thrown'); +}); + +test('without a limit the chain runs as it did before', async (t) => { + const { engine, store, team } = await fixture(t); + const chief = store.getBot(store.state.bots[0].id); + const alice = await team.createBot(chief, { name: 'Alice', role: 'Writer', instructions: 'Drafts.' }); + const bob = await team.createBot(chief, { name: 'Bob', role: 'Reviewer', instructions: 'Reviews.' }); + const group = await team.createGroup(chief, { + name: 'Relay group', members: [alice.id, bob.id], admin: alice.id, + }); + const script = relayScript(); + engine.agent = { async run(input, options) { + const speaker = input.text.includes("It's your turn (Alice).") ? alice : bob; + const ids = started(input, options, `session-${speaker.id}`); + return { ...ids, status: 'finished', text: script(input) }; + } }; + await engine.sendUserMessage(`group:${group.id}`, '@Alice what do you think?', [], 'relay-plain'); + await until(() => idle(engine)); + const messages = await store.getMessages(`group:${group.id}`); + assert.equal(messages.filter((message) => message.role === 'system' && message.key === 'notice.hopLimit').length, 0, + 'no notice without a limit'); + const spoke = messages + .filter((message) => message.role === 'bot' && message.status === 'done') + .map((message) => store.getBot(message.botId)?.name); + assert.deepEqual(spoke, ['Alice', 'Bob', 'Alice', 'Bob'], 'the chain runs to the end, as before'); +});