From 684f8f9ee77972bcd4c4c1d690507efa4786c316 Mon Sep 17 00:00:00 2001 From: zhi Date: Thu, 2 Apr 2026 04:18:45 +0000 Subject: [PATCH] Refine discussion moderator messaging flow --- plans/CSM.md | 6 +- plans/TASKLIST.md | 30 +++---- plugin/core/discussion-messages.ts | 69 ++++++++++++++++ plugin/core/discussion-service.ts | 113 +++++++++------------------ plugin/core/moderator-discord.ts | 34 +++++--- plugin/hooks/before-message-write.ts | 2 +- plugin/hooks/message-sent.ts | 2 +- 7 files changed, 153 insertions(+), 103 deletions(-) create mode 100644 plugin/core/discussion-messages.ts diff --git a/plans/CSM.md b/plans/CSM.md index 0c23b63..aa7872b 100644 --- a/plans/CSM.md +++ b/plans/CSM.md @@ -195,7 +195,7 @@ moderator bot 的工作流完全不使用模型,所有输出均由模板字符 当检测到某 channel 为讨论模式 channel 时,moderator bot 自动发 kickoff message。 -建议内容结构如下: +建议内容结构如下(当前实现中统一收敛在 `plugin/core/discussion-messages.ts`): ```text [Discussion Started] @@ -210,7 +210,7 @@ Instructions: 2. Work toward a concrete conclusion. 3. When the initiator decides the goal has been achieved, the initiator must: - write a summary document to a file - - call the tool: discuss-callback + - call the tool: discuss-callback(summaryPath) - provide the summary document path Completion rule: @@ -331,7 +331,7 @@ Further discussion in this channel is ignored. - 提供结果文档路径 - 用新消息唤醒原工作 channel 上的 Agent 继续执行 -建议模板: +建议模板(当前实现中统一收敛在 `plugin/core/discussion-messages.ts`): ```text [Discussion Result Ready] diff --git a/plans/TASKLIST.md b/plans/TASKLIST.md index dfe8bf2..eb8371a 100644 --- a/plans/TASKLIST.md +++ b/plans/TASKLIST.md @@ -71,13 +71,13 @@ - [ ] 防止 `..`、绝对路径越界、软链接绕过等路径逃逸问题 ### A5. `plugin/core/moderator-discord.ts` -- [ ] 确认当前 `sendModeratorMessage(...)` 是否已足够支撑新流程 -- [ ] 如有必要,补充统一错误日志和返回值处理 -- [ ] 确认可被 discussion service 复用发送: - - [ ] kickoff message - - [ ] idle reminder - - [ ] callback 完成通知 - - [ ] channel closed 固定回复 +- [x] 确认当前 `sendModeratorMessage(...)` 是否已足够支撑新流程 +- [x] 如有必要,补充统一错误日志和返回值处理 +- [x] 确认可被 discussion service 复用发送: + - [x] kickoff message + - [x] idle reminder + - [x] callback 完成通知 + - [x] channel closed 固定回复 ### A6. `plugin/turn-manager.ts` #### A6.1 理解现有轮转机制 @@ -146,10 +146,10 @@ ### A10. moderator 消息模板整理 #### A10.1 kickoff message -- [ ] 定稿 discussion started 模板 -- [ ] 模板中包含 `discussGuide` -- [ ] 模板中明确 initiator 结束责任 -- [ ] 模板中明确 `discuss-callback(summaryPath)` 调用要求 +- [x] 定稿 discussion started 模板 +- [x] 模板中包含 `discussGuide` +- [x] 模板中明确 initiator 结束责任 +- [x] 模板中明确 `discuss-callback(summaryPath)` 调用要求 #### A10.2 idle reminder - [ ] 定稿 discussion idle 模板 @@ -157,10 +157,10 @@ - [ ] 避免提醒文案歧义或像自动总结器 #### A10.3 origin callback message -- [ ] 定稿发回原工作 channel 的结果通知模板 -- [ ] 模板中包含 `summaryPath` -- [ ] 模板中包含来源 discussion channel -- [ ] 模板中明确“继续基于该总结文件推进原任务” +- [x] 定稿发回原工作 channel 的结果通知模板 +- [x] 模板中包含 `summaryPath` +- [x] 模板中包含来源 discussion channel +- [x] 模板中明确“继续基于该总结文件推进原任务” #### A10.4 closed reply - [ ] 定稿 closed channel 固定回复模板 diff --git a/plugin/core/discussion-messages.ts b/plugin/core/discussion-messages.ts new file mode 100644 index 0000000..ea59c80 --- /dev/null +++ b/plugin/core/discussion-messages.ts @@ -0,0 +1,69 @@ +export function buildDiscussionKickoffMessage(discussGuide: string): string { + return [ + "[Discussion Started]", + "", + "This channel was created for a temporary agent discussion.", + "", + "Goal:", + discussGuide, + "", + "Instructions:", + "1. Discuss only the topic above.", + "2. Work toward a concrete conclusion.", + "3. When the initiator decides the goal has been achieved, the initiator must:", + " - write a summary document to a file", + " - call the tool: discuss-callback(summaryPath)", + " - provide the summary document path", + "", + "Completion rule:", + "Only the discussion initiator may finish this discussion.", + "", + "After callback:", + "- this channel will be closed", + "- further discussion messages will be ignored", + "- this channel will remain only for archive/reference", + "- the original work channel will be notified with the summary file path", + ].join("\n"); +} + +export function buildDiscussionIdleReminderMessage(): string { + return [ + "[Discussion Idle]", + "", + "No agent responded in the latest discussion round.", + "If the discussion goal has been achieved, the initiator should now:", + "1. write the discussion summary to a file in the workspace", + "2. call discuss-callback with the summary file path", + "", + "If more discussion is still needed, continue the discussion in this channel.", + ].join("\n"); +} + +export function buildDiscussionClosedMessage(): string { + return [ + "[Channel Closed]", + "", + "This discussion channel has been closed.", + "It is now kept for archive/reference only.", + "Further discussion in this channel is ignored.", + ].join("\n"); +} + +export function buildDiscussionOriginCallbackMessage(summaryPath: string, discussionChannelId: string): string { + return [ + "[Discussion Result Ready]", + "", + "A temporary discussion has completed.", + "", + "Summary file:", + summaryPath, + "", + "Source discussion channel:", + `<#${discussionChannelId}>`, + "", + "Status:", + "completed", + "", + "Continue the original task using the summary file above.", + ].join("\n"); +} diff --git a/plugin/core/discussion-service.ts b/plugin/core/discussion-service.ts index 0ed8a0e..1b4c536 100644 --- a/plugin/core/discussion-service.ts +++ b/plugin/core/discussion-service.ts @@ -2,6 +2,12 @@ import fs from "node:fs"; import path from "node:path"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { closeDiscussion, createDiscussion, getDiscussion, isDiscussionClosed, markDiscussionIdleReminderSent, type DiscussionMetadata } from "./discussion-state.js"; +import { + buildDiscussionClosedMessage, + buildDiscussionIdleReminderMessage, + buildDiscussionKickoffMessage, + buildDiscussionOriginCallbackMessage, +} from "./discussion-messages.js"; import { sendModeratorMessage } from "./moderator-discord.js"; type DiscussionServiceDeps = { @@ -15,76 +21,6 @@ type DiscussionServiceDeps = { export function createDiscussionService(deps: DiscussionServiceDeps) { const workspaceRoot = path.resolve(deps.workspaceRoot || process.cwd()); - function buildKickoffMessage(discussGuide: string): string { - return [ - "[Discussion Started]", - "", - "This channel was created for a temporary agent discussion.", - "", - "Goal:", - discussGuide, - "", - "Instructions:", - "1. Discuss only the topic above.", - "2. Work toward a concrete conclusion.", - "3. When the initiator decides the goal has been achieved, the initiator must:", - " - write a summary document to a file", - " - call the tool: discuss-callback", - " - provide the summary document path", - "", - "Completion rule:", - "Only the discussion initiator may finish this discussion.", - "", - "After callback:", - "- this channel will be closed", - "- further discussion messages will be ignored", - "- this channel will remain only for archive/reference", - "- the original work channel will be notified with the summary file path", - ].join("\n"); - } - - function buildIdleReminderMessage(): string { - return [ - "[Discussion Idle]", - "", - "No agent responded in the latest discussion round.", - "If the discussion goal has been achieved, the initiator should now:", - "1. write the discussion summary to a file in the workspace", - "2. call discuss-callback with the summary file path", - "", - "If more discussion is still needed, continue the discussion in this channel.", - ].join("\n"); - } - - function buildClosedMessage(): string { - return [ - "[Channel Closed]", - "", - "This discussion channel has been closed.", - "It is now kept for archive/reference only.", - "Further discussion in this channel is ignored.", - ].join("\n"); - } - - function buildOriginCallbackMessage(summaryPath: string, discussionChannelId: string): string { - return [ - "[Discussion Result Ready]", - "", - "A temporary discussion has completed.", - "", - "Summary file:", - summaryPath, - "", - "Source discussion channel:", - `<#${discussionChannelId}>`, - "", - "Status:", - "completed", - "", - "Continue the original task using the summary file above.", - ].join("\n"); - } - async function initDiscussion(params: { discussionChannelId: string; originChannelId: string; @@ -104,7 +40,15 @@ export function createDiscussionService(deps: DiscussionServiceDeps) { }); if (deps.moderatorBotToken) { - await sendModeratorMessage(deps.moderatorBotToken, params.discussionChannelId, buildKickoffMessage(params.discussGuide), deps.api.logger); + const result = await sendModeratorMessage( + deps.moderatorBotToken, + params.discussionChannelId, + buildDiscussionKickoffMessage(params.discussGuide), + deps.api.logger, + ); + if (!result.ok) { + deps.api.logger.warn(`dirigent: discussion kickoff message failed channel=${params.discussionChannelId} error=${result.error}`); + } } return metadata; @@ -115,7 +59,15 @@ export function createDiscussionService(deps: DiscussionServiceDeps) { if (!metadata || metadata.status !== "active" || metadata.idleReminderSent) return; markDiscussionIdleReminderSent(channelId); if (deps.moderatorBotToken) { - await sendModeratorMessage(deps.moderatorBotToken, channelId, buildIdleReminderMessage(), deps.api.logger); + const result = await sendModeratorMessage( + deps.moderatorBotToken, + channelId, + buildDiscussionIdleReminderMessage(), + deps.api.logger, + ); + if (!result.ok) { + deps.api.logger.warn(`dirigent: discussion idle reminder failed channel=${channelId} error=${result.error}`); + } } } @@ -163,7 +115,17 @@ export function createDiscussionService(deps: DiscussionServiceDeps) { deps.forceNoReplyForSession(metadata.initiatorSessionId); if (deps.moderatorBotToken) { - await sendModeratorMessage(deps.moderatorBotToken, metadata.originChannelId, buildOriginCallbackMessage(realPath, metadata.discussionChannelId), deps.api.logger); + const result = await sendModeratorMessage( + deps.moderatorBotToken, + metadata.originChannelId, + buildDiscussionOriginCallbackMessage(realPath, metadata.discussionChannelId), + deps.api.logger, + ); + if (!result.ok) { + deps.api.logger.warn( + `dirigent: discussion origin callback notification failed originChannel=${metadata.originChannelId} error=${result.error}`, + ); + } } return { ok: true, summaryPath: realPath, discussion: closed }; @@ -174,7 +136,10 @@ export function createDiscussionService(deps: DiscussionServiceDeps) { if (!metadata || metadata.status !== "closed") return false; if (deps.moderatorUserId && senderId && senderId === deps.moderatorUserId) return true; if (!deps.moderatorBotToken) return true; - await sendModeratorMessage(deps.moderatorBotToken, channelId, buildClosedMessage(), deps.api.logger); + const result = await sendModeratorMessage(deps.moderatorBotToken, channelId, buildDiscussionClosedMessage(), deps.api.logger); + if (!result.ok) { + deps.api.logger.warn(`dirigent: discussion closed reply failed channel=${channelId} error=${result.error}`); + } return true; } diff --git a/plugin/core/moderator-discord.ts b/plugin/core/moderator-discord.ts index 4bca80d..10af355 100644 --- a/plugin/core/moderator-discord.ts +++ b/plugin/core/moderator-discord.ts @@ -20,12 +20,16 @@ export function resolveDiscordUserId(api: OpenClawPluginApi, accountId: string): return userIdFromToken(acct.token); } +export type ModeratorMessageResult = + | { ok: true; status: number; channelId: string; messageId?: string } + | { ok: false; status?: number; channelId: string; error: string }; + export async function sendModeratorMessage( token: string, channelId: string, content: string, logger: { info: (msg: string) => void; warn: (msg: string) => void }, -): Promise { +): Promise { try { const r = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, { method: "POST", @@ -35,15 +39,27 @@ export async function sendModeratorMessage( }, body: JSON.stringify({ content }), }); - if (!r.ok) { - const text = await r.text(); - logger.warn(`dirigent: moderator send failed (${r.status}): ${text}`); - return false; + + const text = await r.text(); + let json: Record | null = null; + try { + json = text ? (JSON.parse(text) as Record) : null; + } catch { + json = null; } - logger.info(`dirigent: moderator message sent to channel=${channelId}`); - return true; + + if (!r.ok) { + const error = `discord api error (${r.status}): ${text || ""}`; + logger.warn(`dirigent: moderator send failed channel=${channelId} ${error}`); + return { ok: false, status: r.status, channelId, error }; + } + + const messageId = typeof json?.id === "string" ? json.id : undefined; + logger.info(`dirigent: moderator message sent to channel=${channelId} messageId=${messageId ?? "unknown"}`); + return { ok: true, status: r.status, channelId, messageId }; } catch (err) { - logger.warn(`dirigent: moderator send error: ${String(err)}`); - return false; + const error = String(err); + logger.warn(`dirigent: moderator send error channel=${channelId}: ${error}`); + return { ok: false, channelId, error }; } } diff --git a/plugin/hooks/before-message-write.ts b/plugin/hooks/before-message-write.ts index 809ad0c..08c08af 100644 --- a/plugin/hooks/before-message-write.ts +++ b/plugin/hooks/before-message-write.ts @@ -24,7 +24,7 @@ type BeforeMessageWriteDeps = { channelId: string, content: string, logger: { info: (m: string) => void; warn: (m: string) => void }, - ) => Promise; + ) => Promise; discussionService?: { maybeSendIdleReminder: (channelId: string) => Promise; getDiscussion: (channelId: string) => { status: string } | undefined; diff --git a/plugin/hooks/message-sent.ts b/plugin/hooks/message-sent.ts index 1449ece..ad6e665 100644 --- a/plugin/hooks/message-sent.ts +++ b/plugin/hooks/message-sent.ts @@ -22,7 +22,7 @@ type MessageSentDeps = { channelId: string, content: string, logger: { info: (m: string) => void; warn: (m: string) => void }, - ) => Promise; + ) => Promise; }; export function registerMessageSentHook(deps: MessageSentDeps): void {