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/CHANGELOG.md +194 -0
- package/README.md +206 -156
- package/client.js +3706 -746
- package/cordis.patch.yml +2 -2
- package/host/agents.js +469 -289
- package/host/background-interactions.js +213 -0
- package/host/conversations.js +45 -0
- package/host/discussion-flow.js +179 -0
- package/host/discussion-runner.js +491 -0
- package/host/domain.js +551 -69
- package/host/engine.js +621 -166
- package/host/execution.js +192 -121
- package/host/native-activity.js +225 -0
- package/host/native-session.js +144 -0
- package/host/pdf.js +43 -6
- package/host/prompt.js +253 -9
- package/host/routes.js +31 -1
- package/host/service.js +751 -127
- package/host/session-export.js +22 -0
- package/host/session-visibility.js +147 -0
- package/host/storage-layout.js +109 -0
- package/host/tools.js +314 -85
- package/host/update-check.js +351 -0
- package/host/util.js +3 -2
- package/host/workspace.js +36 -2
- package/index.js +1 -1
- package/package.json +14 -6
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
}
|
|
59
|
-
return
|
|
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
|
|
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
|
|
112
|
-
const last = repo.
|
|
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
|
-
?
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
357
|
-
|
|
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:
|
|
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 =
|
|
369
|
-
|
|
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 (
|
|
372
|
-
|
|
373
|
-
|
|
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)
|
|
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)
|
|
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),
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
topic: String(args.topic || ''),
|
|
385
|
-
membershipIds,
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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 =
|
|
398
|
-
|
|
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
|
|
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
|
|
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.' },
|