dsh-bot-team 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/host/tools.js CHANGED
@@ -6,12 +6,16 @@
6
6
  * bot_team_send — post a message, @ bots by name, wait for replies
7
7
  * bot_team_review — ask one bot to review a message, return the verdict
8
8
  * bot_team_discuss — run a multi-round discussion, return transcript/summary
9
+ * bot_team_debate — run a debate with named sides and an optional judge
10
+ * bot_team_discussion_control — inspect, pause, resume, or finish a discussion
9
11
  *
10
12
  * Registered globally on the plugin context. Bot members deny these names in
11
13
  * their setup (see agents.js) so a bot cannot recursively drive the group.
12
14
  * Every tool returns concise results with identifiers for follow-up calls.
13
15
  */
14
16
  import { parseMentionsFromText } from './prompt.js';
17
+ import { currentConversationId, conversationIdOf } from './conversations.js';
18
+ import { MAX_DISCUSSION_ROUNDS } from './discussion-flow.js';
15
19
 
16
20
  export const BOT_TEAM_TOOL_NAMES = [
17
21
  'bot_team_list',
@@ -19,12 +23,19 @@ export const BOT_TEAM_TOOL_NAMES = [
19
23
  'bot_team_send',
20
24
  'bot_team_review',
21
25
  'bot_team_discuss',
26
+ 'bot_team_debate',
27
+ 'bot_team_discussion_control',
22
28
  'bot_team_task_create',
23
29
  'bot_team_task_list',
24
30
  'bot_team_task_update',
25
31
  ];
26
32
 
27
33
  const MAX_TEXT_RETURN = 4000;
34
+ const ACTIVE_DISCUSSION_STATUSES = new Set(['running', 'pausing', 'paused', 'finalizing', 'stopping']);
35
+ const TERMINAL_DISCUSSION_STATUSES = new Set(['done', 'stopped']);
36
+ const DEBATE_VERDICTS = ['pro', 'con', 'tie', 'insufficient'];
37
+ const compactObject = (value) =>
38
+ Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
28
39
 
29
40
  function clip(text, max = MAX_TEXT_RETURN) {
30
41
  const value = String(text || '');
@@ -48,21 +59,32 @@ export function registerBotTeamTools({ ctx, service, config, logger }) {
48
59
  };
49
60
 
50
61
  const ready = () => service.ready;
62
+ const guarded = (groupId, operation) => typeof service.withWorkspaceOperation === 'function'
63
+ ? service.withWorkspaceOperation(groupId, () => operation(currentConversationId(service.repo, groupId)))
64
+ : operation(currentConversationId(service.repo, groupId));
65
+ const currentMessage = (message) => conversationIdOf(service.repo, message) === currentConversationId(service.repo, message.groupId);
51
66
 
52
67
  const findMemberByName = (groupId, name) => {
53
68
  const target = String(name || '').trim().replace(/^@/, '').toLowerCase();
54
69
  if (!target) return undefined;
55
- for (const member of service.repo.membersOf(groupId)) {
56
- const bot = service.repo.getBot(member.botId);
57
- if (bot && bot.name.toLowerCase() === target) return { member, bot };
58
- }
59
- return undefined;
70
+ const matches = service.repo.membersOf(groupId)
71
+ .map((member) => ({ member, bot: service.repo.getBot(member.botId) }))
72
+ .filter(({ bot }) => bot && bot.name.toLowerCase() === target);
73
+ if (matches.length > 1) throw Object.assign(new Error(`成员名字 ${name} 有歧义,请先为同名 Bot 设置不同名字`), { code: 'invalid-input' });
74
+ return matches[0];
75
+ };
76
+
77
+ const requireMemberByName = (groupId, name) => {
78
+ const entry = findMemberByName(groupId, name);
79
+ if (!entry) throw Object.assign(new Error(`成员 ${name} 不在群里`), { code: 'invalid-input' });
80
+ if (entry.member.enabled === false) throw Object.assign(new Error(`成员 ${name} 已停用`), { code: 'invalid-input' });
81
+ return entry.member.membershipId;
60
82
  };
61
83
 
62
- const botMessageAfter = (groupId, seq) =>
84
+ const botMessageAfter = (groupId, seq, conversationId = currentConversationId(service.repo, groupId)) =>
63
85
  service.repo
64
86
  .messagesOf(groupId)
65
- .filter((message) => message.seq > seq && message.author?.type === 'bot')
87
+ .filter((message) => message.seq > seq && message.author?.type === 'bot' && conversationIdOf(service.repo, message) === conversationId)
66
88
  .map((message) => ({
67
89
  seq: message.seq,
68
90
  kind: message.kind || 'chat',
@@ -77,11 +99,147 @@ export function registerBotTeamTools({ ctx, service, config, logger }) {
77
99
  return { completed, running: service.engine.isRunning(groupId) };
78
100
  };
79
101
 
102
+ const invalidDiscussionInput = (message) => { throw Object.assign(new Error(message), { code: 'invalid-input' }); };
103
+ const requireGroup = (groupId) => {
104
+ const group = service.repo.getGroup(groupId);
105
+ if (!group) throw Object.assign(new Error('群 ' + groupId + ' 不存在'), { code: 'not-found' });
106
+ return group;
107
+ };
108
+ const contextIds = (groupId, value) => {
109
+ if (value === undefined) return [];
110
+ if (!Array.isArray(value) || value.length > 20 || value.some(id => typeof id !== 'string' || !id.trim())) {
111
+ invalidDiscussionInput('contextMessageIds 必须是最多 20 个本群消息 ID 的数组');
112
+ }
113
+ if (new Set(value).size !== value.length) invalidDiscussionInput('contextMessageIds 不可重复');
114
+ for (const messageId of value) {
115
+ const message = service.repo.getMessage?.(messageId) ?? service.repo.messages?.get(messageId)
116
+ ?? service.repo.messagesOf(groupId).find(entry => entry.messageId === messageId);
117
+ if (!message || message.groupId !== groupId) invalidDiscussionInput('引用消息不存在或不属于本群:' + messageId);
118
+ }
119
+ return [...value];
120
+ };
121
+ const discussionOptions = (args) => {
122
+ const responseLength = args.responseLength ?? 'standard';
123
+ const language = args.language ?? 'auto';
124
+ if (!['short', 'standard', 'detailed'].includes(responseLength)) invalidDiscussionInput('responseLength 无效');
125
+ if (!['auto', 'zh', 'en'].includes(language)) invalidDiscussionInput('language 无效');
126
+ return {
127
+ background: args.background ?? '', goal: args.goal ?? '', responseLength, language,
128
+ contextMessageIds: contextIds(args.groupId, args.contextMessageIds),
129
+ };
130
+ };
131
+ const discussionBrief = (discussion, legacySettled) => {
132
+ const step = discussion.steps?.find(entry => entry.stepId === discussion.currentStepId);
133
+ const phase = discussion.phase ?? step?.phase ?? null;
134
+ return compactObject({
135
+ discussionId: discussion.discussionId,
136
+ groupId: discussion.groupId,
137
+ conversationId: conversationIdOf(service.repo, discussion),
138
+ protocolVersion: discussion.protocolVersion,
139
+ mode: discussion.mode || 'discussion', format: discussion.format || 'classic',
140
+ topic: discussion.topic, rounds: discussion.rounds, partial: discussion.partial === true,
141
+ round: step?.round ?? discussion.currentRound ?? null,
142
+ status: discussion.status, stage: phase, phase,
143
+ side: step?.side ?? discussion.side ?? null,
144
+ revision: discussion.revision ?? null,
145
+ currentStepId: discussion.currentStepId ?? null,
146
+ currentStep: step ? {
147
+ stepId: step.stepId, phase: step.phase, membershipId: step.membershipId,
148
+ side: step.side ?? null, round: step.round, status: step.status,
149
+ attempts: step.attempts?.length ?? 0, messageIds: step.messageIds,
150
+ } : null,
151
+ currentSpeaker: discussion.currentSpeaker ?? null,
152
+ reason: discussion.stopPendingReason ?? discussion.reason ?? (['stopping', 'stopped'].includes(discussion.status)
153
+ ? discussion.stopReason : discussion.pauseReason) ?? null,
154
+ awaitingUser: discussion.awaitingUser === true || discussion.status === 'paused',
155
+ // Legacy wait doubles can signal completion before updating their record.
156
+ // Version 2 uses status alone: paused is responsive, but never terminal.
157
+ settled: TERMINAL_DISCUSSION_STATUSES.has(discussion.status)
158
+ || ((discussion.protocolVersion ?? 1) < 2 && legacySettled === true),
159
+ });
160
+ };
161
+ const discussionResult = (discussion, detail, legacySettled) => {
162
+ const conversationId = conversationIdOf(service.repo, discussion);
163
+ const messages = (detail?.transcript ?? service.repo.messagesOf(discussion.groupId)).filter(message =>
164
+ message.groupId === discussion.groupId && conversationIdOf(service.repo, message) === conversationId
165
+ && message.meta?.discussionId === discussion.discussionId && message.author?.type === 'bot');
166
+ const summaryMessage = messages.filter(message => message.status === 'done' && (message.meta?.summary === true
167
+ || ['summary', 'judgement'].includes(message.meta?.phase))).at(-1);
168
+ const summary = detail?.transcript ? detail.summary : summaryMessage?.text;
169
+ const verdict = detail?.verdict ?? discussion.verdict ?? summaryMessage?.meta?.verdict;
170
+ return {
171
+ discussion: discussionBrief(discussion, legacySettled),
172
+ ...(typeof summary === 'string' ? { summary: clip(summary, 3000) } : {}),
173
+ ...(DEBATE_VERDICTS.includes(verdict) ? { verdict } : {}),
174
+ transcript: messages.map(message => {
175
+ const step = discussion.steps?.find(entry => entry.stepId === message.meta?.stepId);
176
+ return compactObject({
177
+ messageId: message.messageId, groupId: message.groupId, conversationId,
178
+ discussionId: discussion.discussionId, seq: message.seq, name: service.authorName(message),
179
+ stepId: message.meta?.stepId ?? null,
180
+ phase: message.meta?.phase ?? step?.phase ?? null,
181
+ side: message.meta?.side ?? step?.side ?? null,
182
+ round: message.meta?.round ?? step?.round,
183
+ summary: message.meta?.summary === true,
184
+ status: message.status, text: clip(message.text, 1500),
185
+ });
186
+ }),
187
+ };
188
+ };
189
+ const dispatchDiscussion = (action, input) => {
190
+ // The real service owns admission. Older tool doubles expose only the engine.
191
+ if (typeof service.startDiscussion === 'function') {
192
+ return service.dispatch(action, { ...input, expectedConversationId: currentConversationId(service.repo, input.groupId) });
193
+ }
194
+ return guarded(input.groupId, (expectedConversationId) => {
195
+ const payload = { ...input, expectedConversationId };
196
+ if (action === 'discussion.start') return service.engine.startDiscussion(payload);
197
+ if (action === 'discussion.get') return { discussion: service.repo.getDiscussion(input.discussionId) };
198
+ if (typeof service.engine.controlDiscussion !== 'function') throw new Error('当前服务不支持讨论控制');
199
+ return service.engine.controlDiscussion(payload);
200
+ });
201
+ };
202
+ const waitDiscussion = async (discussion, args, detail) => {
203
+ let waited;
204
+ if (args.wait !== false) {
205
+ waited = await service.engine.whenDiscussionSettled(discussion.discussionId, Number(args.timeoutMs) || config.toolWaitTimeoutMs);
206
+ // Re-read messages after waiting; a get snapshot may predate the final reply.
207
+ detail = undefined;
208
+ }
209
+ if (!detail && typeof service.startDiscussion === 'function') {
210
+ detail = await dispatchDiscussion('discussion.get', { groupId: discussion.groupId, discussionId: discussion.discussionId });
211
+ }
212
+ const latest = detail?.discussion || service.repo.getDiscussion(discussion.discussionId) || discussion;
213
+ return discussionResult(latest, detail, waited);
214
+ };
215
+ const discussionContextParameter = {
216
+ type: 'array', items: { type: 'string' }, maxItems: 20, uniqueItems: true,
217
+ description: 'Explicit message IDs to quote from this group, including historical messages. Other groups are rejected.',
218
+ };
219
+ const discussionWaitParameters = {
220
+ wait: { type: 'boolean', description: 'Wait until paused or ended. Defaults to true; paused is not settled.' },
221
+ timeoutMs: { type: 'integer', minimum: 10000, maximum: 900000, description: 'Wait budget in milliseconds. Defaults to ' + config.toolWaitTimeoutMs + '.' },
222
+ };
223
+ const discussionCommonParameters = {
224
+ background: { type: 'string', description: 'Optional background and constraints.' },
225
+ goal: { type: 'string', description: 'Optional goal or decision the discussion should produce.' },
226
+ responseLength: { type: 'string', enum: ['short', 'standard', 'detailed'], description: 'Reply length. Defaults to standard.' },
227
+ language: { type: 'string', enum: ['auto', 'zh', 'en'], description: 'Reply language. Defaults to auto.' },
228
+ contextMessageIds: discussionContextParameter,
229
+ ...discussionWaitParameters,
230
+ };
231
+ const discussionOutputSchema = {
232
+ type: 'object', properties: {
233
+ discussion: { type: 'object' }, transcript: { type: 'array' }, summary: { type: 'string' },
234
+ verdict: { type: 'string', enum: DEBATE_VERDICTS },
235
+ },
236
+ };
237
+
80
238
  // ── bot_team_list ────────────────────────────────────────────────────────
81
239
  define({
82
240
  name: 'bot_team_list',
83
241
  description:
84
- 'List Bot Team groups and their bot members. Returns group ids, titles, topics, member bot names/models, any running discussion, and the latest message. Use it first to find the groupId and bot names that the other bot_team tools need; pass groupId with includeMessages to read that group\'s recent timeline.',
242
+ 'List Bot Team groups and their bot members. Returns group ids, titles, topics, member bot names/models, any active or paused discussion, and the latest message. Use it first to find the groupId and bot names that the other bot_team tools need; pass groupId with includeMessages to read that group\'s recent timeline.',
85
243
  parameters: {
86
244
  type: 'object',
87
245
  properties: {
@@ -107,11 +265,12 @@ export function registerBotTeamTools({ ctx, service, config, logger }) {
107
265
  .filter((group) => group.kind !== 'direct')
108
266
  .filter((group) => !args.groupId || group.groupId === args.groupId)
109
267
  .map((group) => {
110
- const discussions = repo.discussionsOf(group.groupId);
111
- const active = discussions.find((discussion) => discussion.status === 'running');
112
- const last = repo.lastMessageOf(group.groupId);
268
+ const discussions = repo.discussionsOf(group.groupId).filter(currentMessage);
269
+ const active = discussions.find((discussion) => ACTIVE_DISCUSSION_STATUSES.has(discussion.status));
270
+ const last = repo.messagesOf(group.groupId).filter(currentMessage).at(-1);
113
271
  return {
114
272
  groupId: group.groupId,
273
+ currentConversationId: currentConversationId(repo, group.groupId),
115
274
  kind: group.kind || 'group',
116
275
  title: group.title,
117
276
  topic: group.topic || '',
@@ -125,10 +284,10 @@ export function registerBotTeamTools({ ctx, service, config, logger }) {
125
284
  })
126
285
  .filter(Boolean),
127
286
  activeDiscussion: active
128
- ? { discussionId: active.discussionId, topic: active.topic, round: active.currentRound, rounds: active.rounds }
287
+ ? discussionBrief(active)
129
288
  : null,
130
289
  lastMessage: last
131
- ? { seq: last.seq, author: service.authorName(last), status: last.status, text: clip(last.text, 300) }
290
+ ? { messageId: last.messageId, seq: last.seq, author: service.authorName(last), status: last.status, text: clip(last.text, 300) }
132
291
  : null,
133
292
  ...(args.includeMessages && !args.groupId
134
293
  ? {}
@@ -136,8 +295,10 @@ export function registerBotTeamTools({ ctx, service, config, logger }) {
136
295
  ? {
137
296
  messages: repo
138
297
  .messagesOf(group.groupId)
298
+ .filter(currentMessage)
139
299
  .slice(-10)
140
300
  .map((message) => ({
301
+ messageId: message.messageId,
141
302
  seq: message.seq,
142
303
  author: service.authorName(message),
143
304
  kind: message.kind || 'chat',
@@ -236,7 +397,7 @@ export function registerBotTeamTools({ ctx, service, config, logger }) {
236
397
  description: 'Bot names to mention explicitly. Omit to let @Name tokens in text decide.',
237
398
  },
238
399
  wait: { type: 'boolean', description: 'Wait for replies before returning. Defaults to true.' },
239
- timeoutMs: { type: 'integer', minimum: 10000, maximum: 600000, description: 'Reply wait budget. Defaults to 180000.' },
400
+ timeoutMs: { type: 'integer', minimum: 10000, maximum: 600000, description: 'Reply wait budget. Defaults to ' + config.toolWaitTimeoutMs + '.' },
240
401
  },
241
402
  required: ['groupId', 'text'],
242
403
  additionalProperties: false,
@@ -250,18 +411,17 @@ export function registerBotTeamTools({ ctx, service, config, logger }) {
250
411
  const group = service.repo.getGroup(args.groupId);
251
412
  if (!group) throw new Error(`群 ${args.groupId} 不存在`);
252
413
  let mentions = Array.isArray(args.mentions)
253
- ? args.mentions
254
- .map((name) => findMemberByName(group.groupId, name))
255
- .filter(Boolean)
256
- .map((entry) => entry.member.membershipId)
414
+ ? args.mentions.map((name) => requireMemberByName(group.groupId, name))
257
415
  : [];
258
416
  if (mentions.length === 0) mentions = parseMentionsFromText(service.repo, group.groupId, String(args.text || ''));
259
- const { message } = service.engine.postHumanMessage({
417
+ const { message } = await guarded(group.groupId, (expectedConversationId) => service.engine.postHumanMessage({
260
418
  groupId: group.groupId,
261
419
  text: String(args.text || ''),
262
420
  mentions,
263
421
  author: { type: 'human', name: '助手' },
264
- });
422
+ humanInput: false,
423
+ expectedConversationId,
424
+ }));
265
425
  const shouldWait = args.wait !== false;
266
426
  let wait = { completed: true, running: false };
267
427
  if (shouldWait) {
@@ -271,7 +431,7 @@ export function registerBotTeamTools({ ctx, service, config, logger }) {
271
431
  posted: { seq: message.seq, text: clip(message.text, 500) },
272
432
  acceptedMentions: mentions.length,
273
433
  ...wait,
274
- replies: botMessageAfter(group.groupId, message.seq),
434
+ replies: botMessageAfter(group.groupId, message.seq, conversationIdOf(service.repo, message)),
275
435
  };
276
436
  },
277
437
  });
@@ -289,7 +449,7 @@ export function registerBotTeamTools({ ctx, service, config, logger }) {
289
449
  messageSeq: { type: 'integer', description: 'Seq of the message to review. Defaults to the latest bot message.' },
290
450
  instructions: { type: 'string', description: 'Optional review focus, e.g. risks, correctness, missing requirements.' },
291
451
  wait: { type: 'boolean', description: 'Wait for the review before returning. Defaults to true.' },
292
- timeoutMs: { type: 'integer', minimum: 10000, maximum: 600000, description: 'Wait budget. Defaults to 180000.' },
452
+ timeoutMs: { type: 'integer', minimum: 10000, maximum: 600000, description: 'Wait budget. Defaults to ' + config.toolWaitTimeoutMs + '.' },
293
453
  },
294
454
  required: ['groupId', 'reviewer'],
295
455
  additionalProperties: false,
@@ -308,21 +468,22 @@ export function registerBotTeamTools({ ctx, service, config, logger }) {
308
468
  if (Number.isInteger(args.messageSeq)) {
309
469
  target = service.repo.messagesOf(group.groupId).find((message) => message.seq === args.messageSeq);
310
470
  } else {
311
- target = [...service.repo.messagesOf(group.groupId)].reverse().find((message) => message.author?.type === 'bot');
471
+ target = [...service.repo.messagesOf(group.groupId)].reverse().find((message) => message.author?.type === 'bot' && currentMessage(message));
312
472
  }
313
473
  if (!target) throw new Error('找不到要评审的消息');
314
- const result = service.engine.requestReview({
474
+ const result = await guarded(group.groupId, (expectedConversationId) => service.engine.requestReview({
315
475
  groupId: group.groupId,
316
476
  targetMessageId: target.messageId,
317
477
  reviewerMembershipId: reviewer.member.membershipId,
318
478
  instructions: args.instructions || '',
319
- });
479
+ expectedConversationId,
480
+ }));
320
481
  const shouldWait = args.wait !== false;
321
482
  let wait = { completed: true, running: false };
322
483
  if (shouldWait) {
323
484
  wait = await waitIdle(group.groupId, Number(args.timeoutMs) || config.toolWaitTimeoutMs);
324
485
  }
325
- const replies = botMessageAfter(group.groupId, result.requestMessage.seq).filter(
486
+ const replies = botMessageAfter(group.groupId, result.requestMessage.seq, conversationIdOf(service.repo, result.requestMessage)).filter(
326
487
  (message) => message.kind === 'review',
327
488
  );
328
489
  const reply = replies[replies.length - 1] || null;
@@ -340,89 +501,157 @@ export function registerBotTeamTools({ ctx, service, config, logger }) {
340
501
  define({
341
502
  name: 'bot_team_discuss',
342
503
  description:
343
- 'Run a multi-round discussion among Bot Team group members and return the transcript plus an optional summary (共识/分歧/待决). Blocks until the discussion settles unless wait:false. Only one discussion may run per group.',
504
+ 'Run a multi-round discussion and return its transcript and optional summary. Classic defaults to 2 rounds; structured defaults to 3 rounds with viewpoints, challenges and convergence. Wait returns on pause or completion. Only one active discussion or debate is allowed per group.',
344
505
  parameters: {
345
506
  type: 'object',
346
507
  properties: {
347
508
  groupId: { type: 'string', description: 'Group id from bot_team_list.' },
348
509
  topic: { type: 'string', description: 'Discussion topic.' },
349
510
  members: {
350
- type: 'array',
351
- items: { type: 'string' },
352
- description: 'Bot names to include; defaults to every member.',
511
+ type: 'array', items: { type: 'string' }, minItems: 1, uniqueItems: true,
512
+ description: 'Bot names to include; defaults to every available member. An explicit empty list is invalid.',
353
513
  },
354
- rounds: { type: 'integer', minimum: 1, maximum: 5, description: 'Rounds. Defaults to 2.' },
514
+ format: { type: 'string', enum: ['classic', 'structured'], description: 'Discussion format. Defaults to classic.' },
515
+ rounds: { type: 'integer', minimum: 1, maximum: MAX_DISCUSSION_ROUNDS, description: `Classic: 1–${MAX_DISCUSSION_ROUNDS} rounds, default 2. Structured: 2–${MAX_DISCUSSION_ROUNDS} rounds, default 3.` },
355
516
  summarize: { type: 'boolean', description: 'End with a 共识/分歧/待决 summary. Defaults to true.' },
356
- wait: { type: 'boolean', description: 'Wait until the discussion ends. Defaults to true.' },
357
- timeoutMs: { type: 'integer', minimum: 10000, maximum: 900000, description: 'Wait budget. Defaults to 300000.' },
517
+ summaryMember: { type: 'string', description: 'Summary bot name in this group. Defaults to the first participant.' },
518
+ perspectives: { type: 'object', additionalProperties: { type: 'string' }, description: 'Map participant bot names to their perspective or focus.' },
519
+ ...discussionCommonParameters,
358
520
  },
359
521
  required: ['groupId', 'topic'],
360
522
  additionalProperties: false,
361
523
  },
362
524
  output: {
363
- schema: { type: 'object', properties: { discussion: { type: 'object' }, transcript: { type: 'array' } } },
525
+ schema: discussionOutputSchema,
364
526
  render: (_args, value) => textRender(value, 'Bot Team discussion'),
365
527
  },
366
528
  execute: async (args) => {
367
529
  await ready();
368
- const group = service.repo.getGroup(args.groupId);
369
- if (!group) throw new Error(`群 ${args.groupId} 不存在`);
530
+ const group = requireGroup(args.groupId);
531
+ const format = args.format ?? 'classic';
532
+ if (!['classic', 'structured'].includes(format)) invalidDiscussionInput('format 无效');
370
533
  let membershipIds;
371
- if (Array.isArray(args.members) && args.members.length > 0) {
372
- membershipIds = args.members
373
- .map((name) => findMemberByName(group.groupId, name))
374
- .filter(Boolean)
375
- .map((entry) => entry.member.membershipId);
534
+ if (args.members !== undefined) {
535
+ if (!Array.isArray(args.members) || args.members.length === 0) invalidDiscussionInput('members 必须是非空成员名字数组');
536
+ membershipIds = args.members.map(name => requireMemberByName(group.groupId, name));
376
537
  } else {
377
- membershipIds = service.repo.membersOf(group.groupId).map((member) => member.membershipId);
538
+ membershipIds = service.repo.membersOf(group.groupId)
539
+ .filter(member => member.enabled !== false && service.repo.getBot(member.botId)).map(member => member.membershipId);
378
540
  }
379
- if (membershipIds.length === 0) throw new Error('群里没有可用成员');
541
+ if (membershipIds.length === 0) invalidDiscussionInput('群里没有可用成员');
542
+ if (new Set(membershipIds).size !== membershipIds.length) invalidDiscussionInput('讨论成员不能重复');
543
+ if (format === 'structured' && membershipIds.length < 2) invalidDiscussionInput('结构化讨论至少需要两名成员');
544
+ const summaryMembershipId = args.summaryMember === undefined ? undefined : requireMemberByName(group.groupId, args.summaryMember);
545
+ const perspectiveInput = args.perspectives === undefined ? {} : args.perspectives;
546
+ if (!perspectiveInput || typeof perspectiveInput !== 'object' || Array.isArray(perspectiveInput)) invalidDiscussionInput('perspectives 必须是名字到文本的对象');
547
+ const perspectiveEntries = Object.entries(perspectiveInput).map(([name, text]) => {
548
+ const membershipId = requireMemberByName(group.groupId, name);
549
+ if (!membershipIds.includes(membershipId) && membershipId !== summaryMembershipId) invalidDiscussionInput('视角成员未参与本次讨论:' + name);
550
+ if (typeof text !== 'string') invalidDiscussionInput('成员视角必须是文本:' + name);
551
+ return [membershipId, text];
552
+ });
553
+ if (new Set(perspectiveEntries.map(([id]) => id)).size !== perspectiveEntries.length) invalidDiscussionInput('同一成员的视角不可重复');
380
554
  const roundsRaw = Number(args.rounds);
381
- const rounds = Number.isInteger(roundsRaw) ? Math.min(Math.max(roundsRaw, 1), 5) : 2;
382
- const discussion = await service.engine.startDiscussion({
383
- groupId: group.groupId,
384
- topic: String(args.topic || ''),
385
- membershipIds,
386
- rounds,
387
- summarize: args.summarize !== false,
555
+ const rounds = format === 'classic' ? (Number.isInteger(roundsRaw) ? Math.min(Math.max(roundsRaw, 1), MAX_DISCUSSION_ROUNDS) : 2) : (args.rounds ?? 3);
556
+ if (format === 'structured' && (!Number.isInteger(rounds) || rounds < 2 || rounds > MAX_DISCUSSION_ROUNDS)) invalidDiscussionInput('structured rounds 必须是 2 到 ' + MAX_DISCUSSION_ROUNDS + ' 之间的整数');
557
+ const discussion = await dispatchDiscussion('discussion.start', compactObject({
558
+ groupId: group.groupId, topic: String(args.topic || ''), mode: 'discussion', format,
559
+ membershipIds, rounds, summarize: args.summarize !== false, summaryMembershipId,
560
+ perspectives: Object.fromEntries(perspectiveEntries), ...discussionOptions(args),
561
+ }));
562
+ return waitDiscussion(discussion, args);
563
+ },
564
+ });
565
+
566
+ // ── bot_team_debate ──────────────────────────────────────────────────────
567
+ define({
568
+ name: 'bot_team_debate',
569
+ description:
570
+ 'Run a debate between two different group members, with an optional independent judge. Includes openings, alternating questions and answers, and closings. Returns transcript and, with a judge, summary and verdict pro/con/tie/insufficient. Wait returns on pause or completion.',
571
+ parameters: {
572
+ type: 'object',
573
+ properties: {
574
+ groupId: { type: 'string', description: 'Group id from bot_team_list.' },
575
+ topic: { type: 'string', description: 'Debate topic.' },
576
+ pro: { type: 'string', description: 'Pro-side bot name in the group.' },
577
+ con: { type: 'string', description: 'Con-side bot name, different from pro.' },
578
+ judge: { type: 'string', description: 'Optional judge bot name, different from both sides.' },
579
+ proPosition: { type: 'string', description: 'Optional position the pro side should defend.' },
580
+ conPosition: { type: 'string', description: 'Optional position the con side should defend.' },
581
+ rounds: { type: 'integer', minimum: 1, maximum: MAX_DISCUSSION_ROUNDS, description: `Question-and-answer rounds: 1–${MAX_DISCUSSION_ROUNDS}. Defaults to 2.` },
582
+ ...discussionCommonParameters,
583
+ },
584
+ required: ['groupId', 'topic', 'pro', 'con'],
585
+ additionalProperties: false,
586
+ },
587
+ output: {
588
+ schema: discussionOutputSchema,
589
+ render: (_args, value) => textRender(value, 'Bot Team debate'),
590
+ },
591
+ execute: async (args) => {
592
+ await ready();
593
+ const group = requireGroup(args.groupId);
594
+ const proMembershipId = requireMemberByName(group.groupId, args.pro);
595
+ const conMembershipId = requireMemberByName(group.groupId, args.con);
596
+ const judgeMembershipId = args.judge === undefined ? undefined : requireMemberByName(group.groupId, args.judge);
597
+ if (proMembershipId === conMembershipId) invalidDiscussionInput('正方和反方必须是不同成员');
598
+ if ([proMembershipId, conMembershipId].includes(judgeMembershipId)) invalidDiscussionInput('裁判必须独立于正方和反方');
599
+ const rounds = args.rounds ?? 2;
600
+ if (!Number.isInteger(rounds) || rounds < 1 || rounds > MAX_DISCUSSION_ROUNDS) invalidDiscussionInput('辩论 rounds 必须是 1 到 ' + MAX_DISCUSSION_ROUNDS + ' 之间的整数');
601
+ const discussion = await dispatchDiscussion('discussion.start', compactObject({
602
+ groupId: group.groupId, topic: String(args.topic || ''), mode: 'debate',
603
+ proMembershipId, conMembershipId, judgeMembershipId,
604
+ proPosition: args.proPosition ?? '', conPosition: args.conPosition ?? '',
605
+ rounds, summarize: Boolean(judgeMembershipId), ...discussionOptions(args),
606
+ }));
607
+ return waitDiscussion(discussion, args);
608
+ },
609
+ });
610
+
611
+ // ── bot_team_discussion_control ──────────────────────────────────────────
612
+ define({
613
+ name: 'bot_team_discussion_control',
614
+ description:
615
+ 'Inspect or control a discussion/debate by discussionId. get can read historical discussions; all mutations require the current group conversation. pause preserves progress, resume continues, retry retries a failed step, finish moves to the final summary/judgement, stop ends without one, and add_context adds explicit user guidance or same-group quotes. Returns the latest state and transcript.',
616
+ parameters: {
617
+ type: 'object',
618
+ properties: {
619
+ discussionId: { type: 'string', description: 'Discussion id from discuss, debate or list.' },
620
+ operation: { type: 'string', enum: ['get', 'pause', 'resume', 'retry', 'finish', 'stop', 'add_context'] },
621
+ text: { type: 'string', description: 'Optional guidance for add_context.' },
622
+ contextMessageIds: discussionContextParameter,
623
+ expectedRevision: { type: 'integer', minimum: 0, description: 'Optional revision precondition from the latest discussion snapshot.' },
624
+ requestId: { type: 'string', description: 'Optional idempotency key for this control request.' },
625
+ ...discussionWaitParameters,
626
+ wait: { type: 'boolean', description: 'Wait until paused or ended. Defaults to true for mutations; get returns immediately unless wait:true.' },
627
+ },
628
+ required: ['discussionId', 'operation'],
629
+ additionalProperties: false,
630
+ },
631
+ output: {
632
+ schema: discussionOutputSchema,
633
+ render: (_args, value) => textRender(value, 'Bot Team discussion control'),
634
+ },
635
+ execute: async (args) => {
636
+ await ready();
637
+ const discussion = service.repo.getDiscussion(args.discussionId);
638
+ if (!discussion) throw Object.assign(new Error('讨论不存在'), { code: 'not-found' });
639
+ if (!['get', 'pause', 'resume', 'retry', 'finish', 'stop', 'add_context'].includes(args.operation)) invalidDiscussionInput('未知讨论 operation:' + args.operation);
640
+ const input = compactObject({
641
+ groupId: discussion.groupId, discussionId: discussion.discussionId,
642
+ operation: args.operation, text: args.text,
643
+ contextMessageIds: args.contextMessageIds === undefined ? undefined : contextIds(discussion.groupId, args.contextMessageIds),
644
+ expectedRevision: args.expectedRevision, requestId: args.requestId,
388
645
  });
389
- const shouldWait = args.wait !== false;
390
- let settled = true;
391
- if (shouldWait) {
392
- settled = await service.engine.whenDiscussionSettled(
393
- discussion.discussionId,
394
- Number(args.timeoutMs) || config.toolWaitTimeoutMs,
395
- );
646
+ if (args.operation === 'get') {
647
+ const detail = await dispatchDiscussion('discussion.get', input);
648
+ return waitDiscussion(detail.discussion, { ...args, wait: args.wait === true }, detail);
396
649
  }
397
- const latest = service.repo.getDiscussion(discussion.discussionId) || discussion;
398
- const messages = service.repo
399
- .messagesOf(group.groupId)
400
- .filter((message) => message.meta && message.meta.discussionId === discussion.discussionId && message.author?.type === 'bot');
401
- const summary = messages.find((message) => message.meta && message.meta.summary);
402
- return {
403
- discussion: {
404
- discussionId: discussion.discussionId,
405
- topic: discussion.topic,
406
- rounds: discussion.rounds,
407
- status: latest.status,
408
- settled,
409
- },
410
- ...(summary ? { summary: clip(summary.text, 3000) } : {}),
411
- transcript: messages.map((message) => ({
412
- seq: message.seq,
413
- name: service.authorName(message),
414
- ...(message.meta && message.meta.round ? { round: message.meta.round } : {}),
415
- summary: message.meta?.summary === true,
416
- status: message.status,
417
- text: clip(message.text, 1500),
418
- })),
419
- };
650
+ const latest = await dispatchDiscussion('discussion.control', input);
651
+ return waitDiscussion(latest, args);
420
652
  },
421
653
  });
422
654
 
423
- const compactObject = (value) =>
424
- Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
425
-
426
655
  const taskBrief = (task) => {
427
656
  if (!task) return null;
428
657
  const view = service.taskView(task);
@@ -483,7 +712,7 @@ export function registerBotTeamTools({ ctx, service, config, logger }) {
483
712
  },
484
713
  dispatch: { type: 'boolean', description: 'Dispatch immediately after creation. Defaults to false.' },
485
714
  wait: { type: 'boolean', description: 'With dispatch: wait for the delivery. Defaults to true.' },
486
- timeoutMs: { type: 'integer', minimum: 10000, maximum: 600000, description: 'Wait budget. Defaults to 180000.' },
715
+ timeoutMs: { type: 'integer', minimum: 10000, maximum: 600000, description: 'Wait budget. Defaults to ' + config.toolWaitTimeoutMs + '.' },
487
716
  },
488
717
  required: ['groupId', 'title', 'assignee'],
489
718
  additionalProperties: false,
@@ -577,7 +806,7 @@ export function registerBotTeamTools({ ctx, service, config, logger }) {
577
806
  },
578
807
  instructions: { type: 'string', description: 'With review: extra review focus.' },
579
808
  wait: { type: 'boolean', description: 'For dispatch/review/revise: wait for the bot turn. Defaults to true.' },
580
- timeoutMs: { type: 'integer', minimum: 10000, maximum: 600000, description: 'Wait budget. Defaults to 180000.' },
809
+ timeoutMs: { type: 'integer', minimum: 10000, maximum: 600000, description: 'Wait budget. Defaults to ' + config.toolWaitTimeoutMs + '.' },
581
810
  force: { type: 'boolean', description: 'With cancel: also cancel dependent tasks.' },
582
811
  title: { type: 'string', description: 'With update: new title.' },
583
812
  description: { type: 'string', description: 'With update: new description.' },