diff --git a/plugins/minimax-ai/minimax-bot/.minimax-plugin/plugin.json b/plugins/minimax-ai/minimax-bot/.minimax-plugin/plugin.json
index a5927be..7cdbd28 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.21",
+ "version": "0.7.23",
"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 ef2d94d..0dbef51 100644
--- a/plugins/minimax-ai/minimax-bot/README.md
+++ b/plugins/minimax-ai/minimax-bot/README.md
@@ -76,6 +76,10 @@ A shutdown no longer throws away work that never started. Such a task stays queu
The usage ledger counts the time the model really ran. A retried turn waits between the attempts, and that wait was counted as the model's time: a turn that waited half a minute between two attempts was written down as a turn of half a minute, and the panel averaged that up. The duration is now the time the model ran and the panel's average follows it; the wait between the attempts is kept beside it in the ledger (`usage.jsonl`), not inside the duration. A turn the model never ran has no duration to report, so it is left out of the average instead of counting as zero seconds.
+## 0.7.23 The hand-over carries the conclusions of the round
+
+The hand-over between two Bots of a group carried the tail of the previous reply: the last thing one Bot said, not what the round had concluded — and a discussion concludes over several replies, not in the closing line of one of them. The hand-over now carries the conclusions of the round so far, one entry per hop and each with the Bot that said it, the way a seminar hands its conclusion to the next speaker instead of the last sentence that was said. A round with a single hop hands over exactly what it handed over before, byte for byte. The note setting does not change any of this: the hand-over reads the round itself, and a round is built whether or not the group keeps a note. The note stays what it always was, a record of the earlier rounds that lives in the standing brief of every Bot.
+
## 0.7.10 Optional group notes
- A group can keep a note of what it said. After every round the group records the closing lines of the replies, and every Bot of the group gets that record in its standing brief.
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 becd24a..c52f562 100644
--- a/plugins/minimax-ai/minimax-bot/miniapp/client/strings-panels.js
+++ b/plugins/minimax-ai/minimax-bot/miniapp/client/strings-panels.js
@@ -179,7 +179,7 @@ export default {
'panels.group.relayLimit': '接力跳数上限',
'panels.group.relayLimitHint': '0 表示不限:接力链可以一直跑下去。填上数字后(最多 20),这个群里一轮接力最多几个 Bot 说话;到上限时群里会说明本轮收口。「所有人回复」的群里,这个上限连初始成员一起算。',
'panels.group.handover': '把上一位的结论交给下一位',
- 'panels.group.handoverHint': '下一位的提示词里会带上上一位回复的结论(就是它的尾部),省得下一位再翻一遍工具。上一位的原话本来就在「新消息」里,这里是再强调一遍结论。只多一段提示词,不多花回合。',
+ 'panels.group.handoverHint': '下一位的提示词里会带上本轮到目前为止各 Bot 的结论:每个说过的 Bot 一条,前面标着是谁说的;一轮里只有一个人说过时,就是他回复的尾部。带的是结论不是原话(原话本来就在「新消息」里),也只是记录,不代表群里已经达成共识。只多一段提示词,不多花回合。',
'panels.group.notesHeading': '群纪要',
'panels.group.notes': '把本群说过的话记下来',
'panels.group.notesHint': '每轮结束后,群会把各 Bot 回复的结尾记成一份纪要,写在群资料里:清空聊天记录(新建对话)之后纪要还在,新加入的 Bot 也能看到。关掉开关只是不再记、也不再给 Bot 看,记下的内容会保留到你清空为止。纪要只是记录,不代表群里已经达成共识。只多一段提示词,不多花回合。',
@@ -544,7 +544,7 @@ export default {
'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.handoverHint': "The next Bot's prompt carries what the Bots of the round concluded so far: one entry per Bot, tagged with who said it, and the tail of that reply when only one Bot spoke. It carries the conclusions, not the words themselves — those are in the new messages anyway — and it is a record, not an agreement the group reached. It is prompt text only — no extra turn.",
'panels.group.notesHeading': 'Group notes',
'panels.group.notes': 'Keep a record of what this group said',
'panels.group.notesHint': 'After every round the group records the closing lines of the replies as a note, kept with the group settings: the note is still there after the chat was cleared (Start new chat), and a Bot that joins later sees it too. Switching the note off stops the recording and stops handing it to the Bots, but what was recorded stays until you clear it. A note is a record, not an agreement the group reached. It is prompt text only — no extra turn.',
diff --git a/plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs b/plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs
index 6607583..e4f6edc 100644
--- a/plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs
+++ b/plugins/minimax-ai/minimax-bot/miniapp/node/engine.mjs
@@ -9,7 +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, CONTEXT_BLOCK_END, CONTEXT_BLOCK_MARK, handoverRequested, handoverSummary, hopLimit, neutralizeMarks, renderHandover } from './relay-guard.mjs';
+import { CONVERGE_MARK, CONTEXT_BLOCK_END, CONTEXT_BLOCK_MARK, handoverRequested, handoverSummary, hopLimit, neutralizeMarks, renderHandover, roundEntries } from './relay-guard.mjs';
import { appendNote, notesEnabled, renderNote } from './group-note.mjs';
/** @typedef {import('./miniapp-api.js').MiniAppAgentClient} MiniAppAgentClient */
@@ -297,7 +297,7 @@ export class Engine {
}
/**
- * Whether the conclusion of the previous hop travels with the next hop's prompt.
+ * Whether the conclusions of the round travel with the next hop's prompt.
* @param {any} group
* @returns {boolean}
*/
@@ -316,23 +316,29 @@ export class Engine {
}
/**
- * The conclusion of the previous hop, handed over to the next one.
+ * The conclusions of the round so far, 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.
+ * turn, no tool call, no memory write.
+ *
+ * The round is what the group itself produced in this round, so it is the right thing to hand
+ * over: one entry per hop, each with the Bot that said it. The group's own note is not part of
+ * it — that note holds the earlier rounds and lives in the standing brief.
*
* @param {any} group
- * @param {{ botId: string, name: string, text: string, ts: number } | undefined} last
+ * @param {{ speaker: string, text: string }[]} round what the Bots of this round said so far
* @returns {{ from: string, text: string } | undefined}
*/
- handoverFor(group, last) {
+ handoverFor(group, round) {
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 });
+ const text = handoverSummary({ round });
if (!text) return undefined;
- return { from: clean(last.name), text };
+ // The entries that count are the entries the summary shows: an empty entry is not a
+ // conclusion. One real conclusion is one voice, and the block may name it; several are
+ // several Bots, and the block must not put them in one mouth.
+ const entries = roundEntries(round);
+ const from = entries.length === 1 ? clean(entries[0].speaker) : '';
+ return { from, text };
}
@@ -1315,9 +1321,9 @@ export class Engine {
const bot = this.store.getBot(botId);
const fresh = this.store.getGroup(group.id);
if (!bot || !fresh || !fresh.memberIds.includes(botId)) continue;
- // 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);
+ // What the Bots of this round concluded so far rides along with this turn. It is prompt
+ // text, not a model turn: the hand-over costs nothing.
+ const handover = this.handoverFor(fresh, noteRound);
hops += 1;
const result = await this.runBotTurn({ bot, chatId, text: '', kind: 'group', sourceMessageId, sourceMessageIds, signal: options.signal, taskId: options.taskId, handover });
lastResult = result;
@@ -1388,7 +1394,7 @@ export class Engine {
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.
+ // The conclusions of the round: text that rides along with this turn, no model turn.
const hand = handover ? renderHandover(handover) : '';
const head = [];
if (hand) head.push(hand, '');
diff --git a/plugins/minimax-ai/minimax-bot/miniapp/node/group-note.mjs b/plugins/minimax-ai/minimax-bot/miniapp/node/group-note.mjs
index 2268e97..da8b62f 100644
--- a/plugins/minimax-ai/minimax-bot/miniapp/node/group-note.mjs
+++ b/plugins/minimax-ai/minimax-bot/miniapp/node/group-note.mjs
@@ -20,7 +20,7 @@
// Everything in this module is pure: the engine decides *when* the note is written and where it
// goes, this module only says *what* it says. That keeps the rules testable without a model.
-import { neutralizeMarks } from './relay-guard.mjs';
+import { ENTRY_MAX, SPEAKER_MAX, neutralizeMarks, tailOf as cutTail } from './relay-guard.mjs';
import { t } from './i18n.mjs';
/**
@@ -36,10 +36,10 @@ export const GROUP_NOTE_END = '';
export const NOTE_MAX_ENTRIES = 6;
/** How many characters the whole note may take. */
export const NOTE_MAX_CHARS = 1200;
-/** How many characters one entry may take. */
-export const NOTE_ENTRY_MAX = 240;
-/** How many characters the name of a speaker may take. */
-export const NOTE_SPEAKER_MAX = 60;
+/** How many characters one entry may take. The same budget the hand-over uses, defined once in `relay-guard.mjs`. */
+export const NOTE_ENTRY_MAX = ENTRY_MAX;
+/** How many characters the name of a speaker may take. Defined once in `relay-guard.mjs`. */
+export const NOTE_SPEAKER_MAX = SPEAKER_MAX;
/**
* Whether the group keeps a note.
@@ -71,39 +71,18 @@ function cleanEntry(entry) {
return { speaker: speaker.slice(0, NOTE_SPEAKER_MAX), text: tailOf(text, NOTE_ENTRY_MAX) };
}
-/**
- * A word character in the Latin sense: a letter or a digit that a cut can land inside.
- *
- * A CJK ideograph is its own unit, so a cut between two of them already lands on a boundary.
- */
-const LATIN_WORD = /[A-Za-z0-9_]/;
-
-/** How much of a broken word may be dropped to reach the next boundary. */
-const WORD_PREFIX_MAX = 20;
-
/**
* The closing part of a text, cut on a word boundary.
*
- * A plain cut can land inside a word — or inside the half of a tag the neutralising left
- * behind — and the entry would start mid-word. Only a Latin word can be cut in half: Chinese
- * has no spaces, so trimming up to the first whitespace there would drop the whole entry.
+ * The cut itself lives in `relay-guard.mjs`: that module cannot import this one, and both need
+ * the same rule — a plain cut would land inside a word and the entry would start mid-word.
*
* @param {string} text
* @param {number} [max]
* @returns {string}
*/
export function tailOf(text, max = NOTE_ENTRY_MAX) {
- const value = String(text ?? '');
- if (value.length <= max) return value;
- const cut = value.length - max;
- const tail = value.slice(cut);
- // The characters around the cut tell whether it landed inside a Latin word.
- if (!LATIN_WORD.test(value[cut - 1] ?? '') || !LATIN_WORD.test(tail[0] ?? '')) return tail;
- // Drop the rest of that word and the space after it. A word longer than this is left alone:
- // dropping half of a long token would cost more than the fragment is worth.
- const fragment = /^[A-Za-z0-9_]+/.exec(tail)?.[0] ?? '';
- if (!fragment || fragment.length > WORD_PREFIX_MAX) return tail;
- return tail.slice(fragment.length).replace(/^\s+/, '') || tail;
+ return cutTail(text, max);
}
/**
diff --git a/plugins/minimax-ai/minimax-bot/miniapp/node/mcp.mjs b/plugins/minimax-ai/minimax-bot/miniapp/node/mcp.mjs
index 7a47310..9833685 100644
--- a/plugins/minimax-ai/minimax-bot/miniapp/node/mcp.mjs
+++ b/plugins/minimax-ai/minimax-bot/miniapp/node/mcp.mjs
@@ -662,7 +662,7 @@ export function createMcpHandler({ team, engine }) {
result = {
protocolVersion: PROTOCOL_VERSIONS.includes(requested) ? requested : PROTOCOL_VERSIONS[0],
capabilities: { tools: { listChanged: false } },
- serverInfo: { name: 'minimax-bot', version: '0.7.21' },
+ serverInfo: { name: 'minimax-bot', version: '0.7.23' },
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
index 82e6d52..48959ac 100644
--- a/plugins/minimax-ai/minimax-bot/miniapp/node/relay-guard.mjs
+++ b/plugins/minimax-ai/minimax-bot/miniapp/node/relay-guard.mjs
@@ -9,9 +9,14 @@
// 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 summary hand-over (`group.handover`): what the Bots of the round concluded so far
+// travels with the next hop's prompt, so the next Bot does not have to dig the same ground
+// out of the tools again.
+//
+// The hand-over carries the conclusions of the round, not the last sentence of one reply: a
+// discussion concludes over several replies, and the closing line of a single reply is often a
+// question or a courtesy. It is the same idea as a seminar's conclusion — the next speaker gets
+// the results, not the last words that were said.
//
// 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
@@ -23,9 +28,9 @@
import { t } from './i18n.mjs';
-/** Opens the block that carries the previous hop's conclusion. */
+/** Opens the block that carries the conclusions of the round. */
export const HANDOVER_BLOCK_MARK = '';
-/** Closes the block that carries the previous hop's conclusion. */
+/** Closes the block that carries the conclusions of the round. */
export const HANDOVER_BLOCK_END = '';
/** Marks the prompt that asks the current speaker to wrap up. */
export const CONVERGE_MARK = '';
@@ -40,8 +45,23 @@ export const CONTEXT_BLOCK_MARK = '';
export const CONTEXT_BLOCK_END = '';
/** 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. */
+/** How much of the previous reply's tail is kept when the round has a single hop. */
export const HANDOVER_TAIL = 400;
+/**
+ * How many characters one entry that rides into a Bot's prompt may take.
+ *
+ * The hand-over and the group note put the same kind of entry into a prompt, so they share one
+ * budget and it is written down here once. `group-note.mjs` imports these two numbers rather
+ * than keeping a copy of its own.
+ */
+export const ENTRY_MAX = 240;
+/**
+ * How many characters the name of a speaker may take.
+ *
+ * A name is a label and not content, so it is cut short: a long one must not eat the budget of
+ * the round.
+ */
+export const SPEAKER_MAX = 60;
/**
* Protocol marks a reply must never be able to forge: a Bot that writes them into its answer
@@ -143,7 +163,7 @@ export function hopLimit(group) {
}
/**
- * Whether the group wants the conclusion of the previous hop handed over.
+ * Whether the group wants the conclusions of the round handed over.
* @param {any} group
* @returns {boolean}
*/
@@ -222,26 +242,124 @@ export function tailSummary(text, max = HANDOVER_TAIL) {
}
/**
- * The summary of the previous hop.
+ * A word character in the Latin sense: a letter or a digit that a cut can land inside.
+ *
+ * A CJK ideograph is its own unit, so a cut between two of them already lands on a boundary.
+ */
+const LATIN_WORD = /[A-Za-z0-9_]/;
+
+/** How much of a broken word may be dropped to reach the next boundary. */
+const WORD_PREFIX_MAX = 20;
+
+/**
+ * The closing part of a text, cut on a word boundary.
+ *
+ * A plain cut can land inside a word — or inside the half of a tag the neutralising left
+ * behind — and the entry would start mid-word. Only a Latin word can be cut in half: Chinese
+ * has no spaces, so trimming up to the first whitespace there would drop the whole entry.
+ *
+ * @param {string} text
+ * @param {number} [max]
+ * @returns {string}
+ */
+export function tailOf(text, max = ENTRY_MAX) {
+ const value = String(text ?? '');
+ if (value.length <= max) return value;
+ const cut = value.length - max;
+ const tail = value.slice(cut);
+ // The characters around the cut tell whether it landed inside a Latin word.
+ if (!LATIN_WORD.test(value[cut - 1] ?? '') || !LATIN_WORD.test(tail[0] ?? '')) return tail;
+ // Drop the rest of that word and the space after it. A word longer than this is left alone:
+ // dropping half of a long token would cost more than the fragment is worth.
+ const fragment = /^[A-Za-z0-9_]+/.exec(tail)?.[0] ?? '';
+ if (!fragment || fragment.length > WORD_PREFIX_MAX) return tail;
+ return tail.slice(fragment.length).replace(/^\s+/, '') || tail;
+}
+
+/**
+ * The entries of the round that carry something: the speaker and the text, both cleaned.
*
- * 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.
+ * An entry without a speaker or without a text is not a conclusion, so it does not count. The
+ * hand-over has to count the same entries the summary shows: a round with one real conclusion
+ * and one empty entry is a round of one voice, and it must not be handed over under a headline
+ * that speaks of several.
*
- * @param {{ note?: string, reply?: string, max?: number }} input
+ * @param {{ speaker: string, text: string }[]} round what the Bots of this round said so far
+ * @returns {{ speaker: string, text: string }[]}
+ */
+export function roundEntries(round) {
+ const list = Array.isArray(round) ? round : [];
+ const entries = [];
+ for (const item of list) {
+ const speaker = neutralizeMarks(String(item?.speaker ?? '')).replace(/\s+/g, ' ').trim();
+ const text = neutralizeMarks(String(item?.text ?? '')).replace(/\s+/g, ' ').trim();
+ if (!speaker || !text) continue;
+ entries.push({ speaker: speaker.slice(0, SPEAKER_MAX), text });
+ }
+ return entries;
+}
+
+/**
+ * The conclusions of the round so far: one entry per hop, with the name of the Bot that said it.
+ *
+ * The hand-over used to carry the tail of the previous reply. That is the last thing one Bot
+ * said, not what the round concluded: a discussion concludes over several replies, and the
+ * closing line of a single reply is often a question or a courtesy. What the next Bot needs is
+ * the result, the way a seminar hands its conclusion to the next speaker.
+ *
+ * The newest entry is the conclusion of the previous hop and the one that matters most, so the
+ * budget is spent on it: when the whole round does not fit, the oldest entries go first, and
+ * they go whole — half an entry would be a sentence cut in the middle.
+ *
+ * A round with a single hop keeps the hand-over it had before: the tail of that one reply, byte
+ * for byte. The next Bot reads the same words it read before.
+ *
+ * @param {{ speaker: string, text: string }[]} round what the Bots of this round said so far
+ * @param {number} [max]
* @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;
+export function roundSummary(round, max = HANDOVER_MAX) {
+ const entries = roundEntries(round);
+ if (!entries.length) return '';
+ if (entries.length === 1) {
+ // One hop: the hand-over of before — the tail of that reply, byte for byte.
+ return tailSummary(entries[0].text, Math.min(max, HANDOVER_TAIL));
+ }
+ const kept = [];
+ let chars = 0;
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
+ const entry = entries[index];
+ const part = `[${entry.speaker}] ${tailOf(entry.text, ENTRY_MAX)}`;
+ const size = part.length + (kept.length ? 3 : 0);
+ if (chars + size > max) break;
+ kept.unshift(part);
+ chars += size;
+ }
+ return kept.join(' / ');
+}
+
+/**
+ * The summary the hand-over carries.
+ *
+ * The round is the only thing it carries: the conclusions the Bots of this round reached so
+ * far. The note of the group is not part of it — that note lives in the standing brief, it
+ * holds the earlier rounds, and it is a record of what was said rather than a result of this
+ * round.
+ *
+ * @param {{ round?: { speaker: string, text: string }[], max?: number }} input
+ * @returns {string}
+ */
+export function handoverSummary({ round, max = HANDOVER_MAX } = {}) {
+ return roundSummary(round, max);
}
/**
* 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.
*
+ * One hop is attributed to the Bot that said it — the block names it. Several hops are not:
+ * the conclusions belong to several Bots, and the block must not put them all in one mouth.
+ *
* @param {{ from?: string, text?: string }} input
* @returns {string}
*/
@@ -251,7 +369,9 @@ export function renderHandover({ from, text } = {}) {
const who = String(from ?? '').trim();
return [
HANDOVER_BLOCK_MARK,
- t('relay.handover', { from: who, text: body }),
+ who
+ ? t('relay.handover', { from: who, text: body })
+ : t('relay.handoverRound', { 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 21ce07d..485d19e 100644
--- a/plugins/minimax-ai/minimax-bot/miniapp/node/strings.mjs
+++ b/plugins/minimax-ai/minimax-bot/miniapp/node/strings.mjs
@@ -227,6 +227,7 @@ export default {
'group.yourTurn': '现在轮到你({name})发言。没有新内容要补充就只回复 [PASS]。',
'relay.converge': '已达接力上限({max} 跳),请就上面的讨论给出收口总结,不要再 @ 别人。',
'relay.handover': '上一位({from})说:{text}',
+ 'relay.handoverRound': '本轮到目前为止各位的结论(这是记录,不是已达成的共识):{text}',
'relay.fromUserSession': '用户的 MiniMax Code 会话',
'relay.prompt': '[来自「{from}」的消息。请处理后直接回复,你的回复会作为结果交还给对方。]',
'schedule.trigger': '[定时任务「{name}」自动触发]',
@@ -547,6 +548,7 @@ export default {
'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.handoverRound": "What each speaker concluded in this round so far (a record, not an agreement): {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]',
diff --git a/plugins/minimax-ai/minimax-bot/package.json b/plugins/minimax-ai/minimax-bot/package.json
index af689e4..4c0b9f0 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.21",
+ "version": "0.7.23",
"private": true,
"type": "module",
"mcode": {
diff --git a/plugins/minimax-ai/minimax-bot/tests/handover-note.test.mjs b/plugins/minimax-ai/minimax-bot/tests/handover-note.test.mjs
new file mode 100644
index 0000000..8c293aa
--- /dev/null
+++ b/plugins/minimax-ai/minimax-bot/tests/handover-note.test.mjs
@@ -0,0 +1,298 @@
+// @ts-check
+// The hand-over of a group, written as executable assertions.
+//
+// The point of this file: the claims the hand-over makes are not left to a code review.
+//
+// 1. a round with one hop hands over exactly what it handed over before, byte for byte;
+// 2. a round with several hops hands over the conclusions of the round, under the round's own
+// headline — the conclusions of several Bots do not go into one mouth;
+// 3. the hand-over stays inside its budget: the oldest conclusion goes first and goes whole;
+// 4. the note of the group has nothing to do with the hand-over;
+// 5. a reply cannot forge the hand-over block;
+// 6. two real rounds: the second round carries the second round and not the first;
+// 7. the first hop of a round gets no hand-over at all.
+
+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 {
+ ENTRY_MAX, HANDOVER_BLOCK_END, HANDOVER_BLOCK_MARK, HANDOVER_MAX, HANDOVER_TAIL,
+ 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 { setLocale } 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) {
+ const dir = await mkdtemp(join(tmpdir(), 'bot-handover-note-'));
+ const store = new Store(dir);
+ const memory = new MemoryStore(dir);
+ await store.init();
+ await memory.init();
+ setLocale('en');
+ const events = [];
+ const engine = new Engine({
+ store, memory, logger: { warn() {} }, broadcast: (event) => events.push(event), retryDelaysMs: [],
+ });
+ 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, events };
+}
+
+/** A group that asks for the hand-over. */
+function group(extra = {}) {
+ return { id: 'g1', handover: true, ...extra };
+}
+
+/** The reply of a hop: long enough to be cut at the tail. */
+const REPLY = {
+ speaker: 'Alice',
+ text: `${'the analysis of the previous hop. '.repeat(40)}Conclusion: we go with plan B.`,
+};
+
+/** The hand-over block of a prompt, or '' when the prompt carries none. */
+function blockOf(prompt) {
+ const start = prompt.indexOf(HANDOVER_BLOCK_MARK);
+ if (start < 0) return '';
+ const end = prompt.indexOf(HANDOVER_BLOCK_END, start);
+ return prompt.slice(start, end + HANDOVER_BLOCK_END.length);
+}
+
+// ---------------------------------------------------------------- 1. one hop is byte for byte
+
+test('a round with one hop hands over exactly what it handed over before', async (t) => {
+ const { engine } = await fixture(t);
+ const handover = engine.handoverFor(group(), [{ speaker: 'Alice', text: REPLY.text }]);
+ assert.ok(handover, 'the hand-over carries the conclusion');
+ // Byte for byte the tail of that reply — not a prefix, not a shorter tail, the same tail.
+ assert.equal(handover.text, tailSummary(REPLY.text, HANDOVER_TAIL));
+ // One hop belongs to one Bot, so the block may name it.
+ assert.equal(handover.from, 'Alice');
+ // And the whole block is the one the next Bot read before: same mark, same wording, same
+ // text. The wording is written out here on purpose — read back out of the strings it would
+ // follow whatever it says, so a change of it would go unnoticed.
+ assert.equal(
+ renderHandover(handover),
+ [
+ HANDOVER_BLOCK_MARK,
+ `The previous speaker (Alice) said: ${tailSummary(REPLY.text, HANDOVER_TAIL)}`,
+ HANDOVER_BLOCK_END,
+ ].join('\n'),
+ );
+});
+
+// ---------------------------------------------------------------- 2. several hops
+
+test('a round with several hops hands over the conclusions of the round', async (t) => {
+ const { engine } = await fixture(t);
+ const handover = engine.handoverFor(group(), [
+ { speaker: 'Alice', text: 'we compared A and B' },
+ { speaker: 'Bob', text: 'the conclusion is plan B' },
+ ]);
+ assert.ok(handover, 'the hand-over carries the conclusions');
+ assert.ok(handover.text.includes('we compared A and B'), 'the first conclusion is missing');
+ assert.ok(handover.text.includes('the conclusion is plan B'), 'the second conclusion is missing');
+ // Several Bots concluded something, so the block must not put them in one mouth.
+ assert.equal(handover.from, '', 'the conclusions of several Bots are not attributed to one of them');
+
+ for (const locale of ['zh', 'en']) {
+ setLocale(locale);
+ const block = renderHandover(handover);
+ const lines = block.split('\n');
+ assert.equal(lines[0], HANDOVER_BLOCK_MARK, 'the hand-over is still a block');
+ assert.equal(lines[2], HANDOVER_BLOCK_END, 'the block is closed');
+ // The round has its own headline, and the headline says what it is: a record of what the
+ // Bots said, not an agreement the group reached. The words are written out here on
+ // purpose — a headline read back out of the strings would follow whatever they say, so
+ // dropping the qualification would go unnoticed.
+ if (locale === 'zh') {
+ assert.ok(lines[1].includes('这是记录,不是已达成的共识'), 'the round block does not say that it is a record');
+ } else {
+ assert.ok(lines[1].includes('a record, not an agreement'), 'the round block does not say that it is a record');
+ }
+ // And the conclusions of several Bots do not go into one mouth.
+ assert.ok(!lines[1].includes('上一位'), 'the round was attributed to one speaker');
+ assert.ok(!lines[1].includes('The previous speaker'), 'the round was attributed to one speaker');
+ assert.ok(lines[1].includes('we compared A and B'), 'the conclusions are not in the block');
+ }
+ setLocale('en');
+});
+
+// ---------------------------------------------------------------- 2b. one voice, one entry
+
+test('a round with one real conclusion is a round of one voice, even next to an empty entry', async (t) => {
+ const { engine } = await fixture(t);
+ // A reply that is only whitespace is not a conclusion, so it does not count.
+ const handover = engine.handoverFor(group(), [
+ { speaker: 'Alice', text: ' ' },
+ { speaker: 'Bob', text: 'the conclusion is plan B' },
+ ]);
+ assert.ok(handover, 'the hand-over carries the conclusion');
+ // One voice, so the block may name it.
+ assert.equal(handover.from, 'Bob');
+ // And it must not wear the headline of several Bots: one Bot's conclusion is not "各位".
+ const block = renderHandover(handover);
+ assert.ok(!block.includes('a record, not an agreement'), 'one voice was handed over as a round of several');
+ assert.ok(block.includes('The previous speaker (Bob) said:'), 'the one voice is not attributed to its Bot');
+ // The text is the tail of that one reply, byte for byte.
+ assert.equal(handover.text, tailSummary('the conclusion is plan B', HANDOVER_TAIL));
+});
+
+// ---------------------------------------------------------------- 3. the budget
+
+test('the hand-over stays inside its budget and drops the oldest conclusion whole', async (t) => {
+ const { engine } = await fixture(t);
+ // Every conclusion is far longer than one entry may be, so each of them has to be cut.
+ const handover = engine.handoverFor(group(), [
+ { speaker: 'Oldest', text: `${'a'.repeat(400)} OLDEST` },
+ { speaker: 'Middle', text: `${'b'.repeat(400)} MIDDLE` },
+ { speaker: 'Newest', text: `${'c'.repeat(400)} NEWEST` },
+ ]);
+ assert.ok(handover, 'the hand-over carries the conclusions');
+ assert.ok(handover.text.length <= HANDOVER_MAX, 'the hand-over stays within its budget');
+ // Every conclusion is cut at its own budget, never in the middle of a word, and the cut keeps
+ // the end of it — the conclusion sits at the end of a reply.
+ const entries = handover.text.split(' / ');
+ for (const entry of entries) {
+ const payload = entry.replace(/^\[[^\]]+\]\s*/, '');
+ assert.ok(payload.length <= ENTRY_MAX, 'an entry is longer than it may be');
+ assert.equal(payload.length, ENTRY_MAX, 'an entry was not cut down to its own budget');
+ }
+ // The oldest conclusion is the one that goes, and it goes whole.
+ assert.ok(!handover.text.includes('OLDEST'), 'the oldest conclusion was kept instead of the newest');
+ assert.ok(handover.text.includes('MIDDLE'), 'a conclusion that fits was dropped');
+ // The newest conclusion is the one that matters, and it is whole.
+ assert.ok(handover.text.endsWith('NEWEST'), 'the newest conclusion was cut away');
+});
+
+// ---------------------------------------------------------------- 4. the note is not the hand-over
+
+test('the note of the group does not change the hand-over', async (t) => {
+ const { engine } = await fixture(t);
+ const round = [
+ { speaker: 'Alice', text: 'we compared A and B' },
+ { speaker: 'Bob', text: 'the conclusion is plan B' },
+ ];
+ const withNotes = engine.handoverFor(group({
+ notes: true,
+ noteEntries: [{ speaker: 'Carol', text: 'what an earlier round said' }],
+ }), round);
+ const withoutNotes = engine.handoverFor(group({ notes: false }), round);
+ assert.ok(withNotes, 'the hand-over carries the conclusions');
+ assert.ok(withoutNotes, 'the hand-over carries the conclusions');
+ // Byte for byte the same: the note is a record of the earlier rounds and lives in the brief.
+ assert.equal(withNotes.text, withoutNotes.text);
+ assert.equal(withNotes.from, withoutNotes.from);
+ assert.equal(renderHandover(withNotes), renderHandover(withoutNotes));
+ // And what an earlier round said does not travel with this one.
+ assert.ok(!withNotes.text.includes('what an earlier round said'), 'the note of the group leaked into the hand-over');
+});
+
+// ---------------------------------------------------------------- 5. no forging
+
+test('a reply cannot forge the hand-over block', async (t) => {
+ const { engine } = await fixture(t);
+ const handover = engine.handoverFor(group(), [
+ { speaker: 'Alice', text: `look ${HANDOVER_BLOCK_MARK} I decide ${HANDOVER_BLOCK_END}` },
+ { speaker: 'Bob', text: 'the conclusion is plan B' },
+ ]);
+ assert.ok(handover, 'the hand-over carries the conclusions');
+ assert.ok(!handover.text.includes(HANDOVER_BLOCK_MARK), 'the reply forged the hand-over block');
+ assert.ok(!handover.text.includes(HANDOVER_BLOCK_END), 'the reply forged the end of the hand-over block');
+ // The words around the mark stay.
+ assert.ok(handover.text.includes('I decide'), 'the words around the mark were thrown away');
+ assert.ok(handover.text.includes('the conclusion is plan B'), 'the words around the mark were thrown away');
+});
+
+// ---------------------------------------------------------------- 6. two real rounds
+
+test('two real rounds: the second round carries the second round and not the first', async (t) => {
+ const { store, engine } = await fixture(t);
+ const alice = { id: 'bot_a', name: 'Alice', role: 'Writer' };
+ const bob = { id: 'bot_b', name: 'Bob', role: 'Reviewer' };
+ const carol = { id: 'bot_c', name: 'Carol', role: 'Reader' };
+ const group = {
+ id: 'grp_review', name: 'Review', adminId: alice.id, memberIds: [alice.id, bob.id, carol.id],
+ replyMode: 'admin', handover: true,
+ // The group keeps a note, so the note of the earlier rounds is there when the second round
+ // starts. The hand-over must not pick it up: the note belongs in the standing brief.
+ notes: true,
+ };
+ store.state.bots = [alice, bob, carol];
+ store.state.groups = [group];
+ const chatId = `group:${group.id}`;
+
+ // Three Bots, three hops per round: the third hop of a round gets the conclusions of the
+ // first two, so the round headline is exercised by a real run and not only by a unit test.
+ const replies = [
+ 'ROUND1_ALPHA @Bob take a look',
+ 'ROUND1_BETA @Carol your turn',
+ 'ROUND1_GAMMA done',
+ 'ROUND2_ALPHA @Bob and now this',
+ 'ROUND2_BETA @Carol now you',
+ 'ROUND2_GAMMA done',
+ ];
+ const calls = [];
+ engine.agent = { async run(input, options) {
+ const who = input.text.includes("It's your turn (Alice).") ? 'Alice'
+ : input.text.includes("It's your turn (Bob).") ? 'Bob' : 'Carol';
+ calls.push({ who, text: input.text });
+ const sessionId = `session-${calls.length}`;
+ options.onEvent({ type: 'turn.started', sessionId, turnId: `turn-${sessionId}` });
+ return { sessionId, turnId: `turn-${sessionId}`, status: 'finished', text: replies[calls.length - 1] };
+ } };
+
+ await engine.sendUserMessage(chatId, '@Alice first request');
+ await until(() => calls.length === 3 && idle(engine));
+ await engine.sendUserMessage(chatId, '@Alice second request');
+ await until(() => calls.length === 6 && idle(engine));
+ assert.deepEqual(calls.map((call) => call.who), ['Alice', 'Bob', 'Carol', 'Alice', 'Bob', 'Carol']);
+
+ // The second hop of the second round: it gets the conclusion of the first hop of that round.
+ const secondHop = blockOf(calls[4].text);
+ assert.ok(secondHop.includes('ROUND2_ALPHA'), 'the conclusion of this round is missing');
+ assert.ok(!secondHop.includes('ROUND1_ALPHA'), 'a sentence of the first round travelled along');
+ assert.ok(!secondHop.includes('ROUND1_BETA'), 'a sentence of the first round travelled along');
+ assert.ok(!secondHop.includes('ROUND1_GAMMA'), 'a sentence of the first round travelled along');
+
+ // The third hop of the second round: it gets the conclusions of both hops before it.
+ const thirdHop = blockOf(calls[5].text);
+ assert.ok(thirdHop.includes('ROUND2_ALPHA'), 'the first conclusion of this round is missing');
+ assert.ok(thirdHop.includes('ROUND2_BETA'), 'the second conclusion of this round is missing');
+ assert.ok(!thirdHop.includes('ROUND1_'), 'a sentence of the first round travelled along');
+ assert.ok(thirdHop.includes('a record, not an agreement'), 'the round does not say that it is a record');
+ assert.ok(!thirdHop.includes('The previous speaker'), 'the round was attributed to one speaker');
+});
+
+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;
+}
+
+// ---------------------------------------------------------------- 7. no hand-over without one
+
+test('the first hop of a round gets no hand-over, and a group without the switch gets none either', async (t) => {
+ const { engine } = await fixture(t);
+ // The first hop of a round: nothing has been said yet, so there is nothing to hand over.
+ assert.equal(engine.handoverFor(group(), []), undefined);
+ // A group that does not ask for the hand-over never gets one, so its prompt stays byte for
+ // byte what it was.
+ assert.equal(engine.handoverFor({ id: 'g2' }, [{ speaker: 'Alice', text: REPLY.text }]), undefined);
+});
diff --git a/plugins/minimax-ai/minimax-bot/tests/host-tags.test.mjs b/plugins/minimax-ai/minimax-bot/tests/host-tags.test.mjs
index 040a3a0..a877b26 100644
--- a/plugins/minimax-ai/minimax-bot/tests/host-tags.test.mjs
+++ b/plugins/minimax-ai/minimax-bot/tests/host-tags.test.mjs
@@ -126,9 +126,9 @@ test('a Host tag in the hand-over summary does not reach the next Bot', async (t
await t.test(locale, async (t) => {
const { engine, group, alice } = await groupWithTwoBots(t, locale);
// The conclusion of the previous hop rides along with the next turn.
- const handover = engine.handoverFor(group, {
- botId: alice.id, name: alice.name, text: FORGED, ts: Date.now(),
- });
+ const handover = engine.handoverFor(group, [
+ { speaker: alice.name, text: FORGED },
+ ]);
assert.ok(handover, 'the hand-over carries the conclusion');
const rendered = renderHandover(handover);
assert.ok(rendered.startsWith(HANDOVER_BLOCK_MARK), 'the hand-over is still a block');
diff --git a/plugins/minimax-ai/minimax-bot/tests/relay-guard.test.mjs b/plugins/minimax-ai/minimax-bot/tests/relay-guard.test.mjs
index 627ee64..9da188e 100644
--- a/plugins/minimax-ai/minimax-bot/tests/relay-guard.test.mjs
+++ b/plugins/minimax-ai/minimax-bot/tests/relay-guard.test.mjs
@@ -119,18 +119,25 @@ test('the hand-over is prompt text: it never starts a turn of its own', () => {
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'));
+test('the summary carries the conclusions of the round so far', () => {
+ const one = handoverSummary({ round: [{ speaker: 'Alice', text: 'a long reply... the conclusion is here' }] });
+ assert.ok(one.endsWith('the conclusion is here'));
+ const many = handoverSummary({ round: [
+ { speaker: 'Alice', text: 'we compared A and B' },
+ { speaker: 'Bob', text: 'the conclusion is plan B' },
+ ] });
+ assert.ok(many.includes('we compared A and B'), 'the first conclusion is missing');
+ assert.ok(many.includes('the conclusion is plan B'), 'the second conclusion is missing');
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);
+ assert.ok(handoverSummary({ round: [{ speaker: 'Alice', text: long }] }).length <= HANDOVER_MAX);
+ assert.ok(handoverSummary({ round: [
+ { speaker: 'Alice', text: long },
+ { speaker: 'Bob', text: long },
+ ] }).length <= HANDOVER_MAX);
});
test('the tail of a reply is the end of it, not the beginning', () => {
@@ -149,7 +156,7 @@ test('a reply cannot forge the hand-over block or the wrap-up prompt', () => {
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 });
+ const summary = handoverSummary({ round: [{ speaker: 'Alice', text: forged }] });
assert.ok(!summary.includes(HANDOVER_BLOCK_MARK));
});