refactor #22

Merged
hzhang merged 33 commits from refactor into main 2026-04-10 07:49:57 +00:00
7 changed files with 153 additions and 103 deletions
Showing only changes of commit 684f8f9ee7 - Show all commits

View File

@@ -195,7 +195,7 @@ moderator bot 的工作流完全不使用模型,所有输出均由模板字符
当检测到某 channel 为讨论模式 channel 时moderator bot 自动发 kickoff message。 当检测到某 channel 为讨论模式 channel 时moderator bot 自动发 kickoff message。
建议内容结构如下: 建议内容结构如下(当前实现中统一收敛在 `plugin/core/discussion-messages.ts`
```text ```text
[Discussion Started] [Discussion Started]
@@ -210,7 +210,7 @@ Instructions:
2. Work toward a concrete conclusion. 2. Work toward a concrete conclusion.
3. When the initiator decides the goal has been achieved, the initiator must: 3. When the initiator decides the goal has been achieved, the initiator must:
- write a summary document to a file - write a summary document to a file
- call the tool: discuss-callback - call the tool: discuss-callback(summaryPath)
- provide the summary document path - provide the summary document path
Completion rule: Completion rule:
@@ -331,7 +331,7 @@ Further discussion in this channel is ignored.
- 提供结果文档路径 - 提供结果文档路径
- 用新消息唤醒原工作 channel 上的 Agent 继续执行 - 用新消息唤醒原工作 channel 上的 Agent 继续执行
建议模板: 建议模板(当前实现中统一收敛在 `plugin/core/discussion-messages.ts`
```text ```text
[Discussion Result Ready] [Discussion Result Ready]

View File

@@ -71,13 +71,13 @@
- [ ] 防止 `..`、绝对路径越界、软链接绕过等路径逃逸问题 - [ ] 防止 `..`、绝对路径越界、软链接绕过等路径逃逸问题
### A5. `plugin/core/moderator-discord.ts` ### A5. `plugin/core/moderator-discord.ts`
- [ ] 确认当前 `sendModeratorMessage(...)` 是否已足够支撑新流程 - [x] 确认当前 `sendModeratorMessage(...)` 是否已足够支撑新流程
- [ ] 如有必要,补充统一错误日志和返回值处理 - [x] 如有必要,补充统一错误日志和返回值处理
- [ ] 确认可被 discussion service 复用发送: - [x] 确认可被 discussion service 复用发送:
- [ ] kickoff message - [x] kickoff message
- [ ] idle reminder - [x] idle reminder
- [ ] callback 完成通知 - [x] callback 完成通知
- [ ] channel closed 固定回复 - [x] channel closed 固定回复
### A6. `plugin/turn-manager.ts` ### A6. `plugin/turn-manager.ts`
#### A6.1 理解现有轮转机制 #### A6.1 理解现有轮转机制
@@ -146,10 +146,10 @@
### A10. moderator 消息模板整理 ### A10. moderator 消息模板整理
#### A10.1 kickoff message #### A10.1 kickoff message
- [ ] 定稿 discussion started 模板 - [x] 定稿 discussion started 模板
- [ ] 模板中包含 `discussGuide` - [x] 模板中包含 `discussGuide`
- [ ] 模板中明确 initiator 结束责任 - [x] 模板中明确 initiator 结束责任
- [ ] 模板中明确 `discuss-callback(summaryPath)` 调用要求 - [x] 模板中明确 `discuss-callback(summaryPath)` 调用要求
#### A10.2 idle reminder #### A10.2 idle reminder
- [ ] 定稿 discussion idle 模板 - [ ] 定稿 discussion idle 模板
@@ -157,10 +157,10 @@
- [ ] 避免提醒文案歧义或像自动总结器 - [ ] 避免提醒文案歧义或像自动总结器
#### A10.3 origin callback message #### A10.3 origin callback message
- [ ] 定稿发回原工作 channel 的结果通知模板 - [x] 定稿发回原工作 channel 的结果通知模板
- [ ] 模板中包含 `summaryPath` - [x] 模板中包含 `summaryPath`
- [ ] 模板中包含来源 discussion channel - [x] 模板中包含来源 discussion channel
- [ ] 模板中明确“继续基于该总结文件推进原任务” - [x] 模板中明确“继续基于该总结文件推进原任务”
#### A10.4 closed reply #### A10.4 closed reply
- [ ] 定稿 closed channel 固定回复模板 - [ ] 定稿 closed channel 固定回复模板

View File

@@ -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");
}

View File

@@ -2,6 +2,12 @@ import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { closeDiscussion, createDiscussion, getDiscussion, isDiscussionClosed, markDiscussionIdleReminderSent, type DiscussionMetadata } from "./discussion-state.js"; 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"; import { sendModeratorMessage } from "./moderator-discord.js";
type DiscussionServiceDeps = { type DiscussionServiceDeps = {
@@ -15,76 +21,6 @@ type DiscussionServiceDeps = {
export function createDiscussionService(deps: DiscussionServiceDeps) { export function createDiscussionService(deps: DiscussionServiceDeps) {
const workspaceRoot = path.resolve(deps.workspaceRoot || process.cwd()); 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: { async function initDiscussion(params: {
discussionChannelId: string; discussionChannelId: string;
originChannelId: string; originChannelId: string;
@@ -104,7 +40,15 @@ export function createDiscussionService(deps: DiscussionServiceDeps) {
}); });
if (deps.moderatorBotToken) { 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; return metadata;
@@ -115,7 +59,15 @@ export function createDiscussionService(deps: DiscussionServiceDeps) {
if (!metadata || metadata.status !== "active" || metadata.idleReminderSent) return; if (!metadata || metadata.status !== "active" || metadata.idleReminderSent) return;
markDiscussionIdleReminderSent(channelId); markDiscussionIdleReminderSent(channelId);
if (deps.moderatorBotToken) { 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); deps.forceNoReplyForSession(metadata.initiatorSessionId);
if (deps.moderatorBotToken) { 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 }; return { ok: true, summaryPath: realPath, discussion: closed };
@@ -174,7 +136,10 @@ export function createDiscussionService(deps: DiscussionServiceDeps) {
if (!metadata || metadata.status !== "closed") return false; if (!metadata || metadata.status !== "closed") return false;
if (deps.moderatorUserId && senderId && senderId === deps.moderatorUserId) return true; if (deps.moderatorUserId && senderId && senderId === deps.moderatorUserId) return true;
if (!deps.moderatorBotToken) 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; return true;
} }

View File

@@ -20,12 +20,16 @@ export function resolveDiscordUserId(api: OpenClawPluginApi, accountId: string):
return userIdFromToken(acct.token); 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( export async function sendModeratorMessage(
token: string, token: string,
channelId: string, channelId: string,
content: string, content: string,
logger: { info: (msg: string) => void; warn: (msg: string) => void }, logger: { info: (msg: string) => void; warn: (msg: string) => void },
): Promise<boolean> { ): Promise<ModeratorMessageResult> {
try { try {
const r = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, { const r = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, {
method: "POST", method: "POST",
@@ -35,15 +39,27 @@ export async function sendModeratorMessage(
}, },
body: JSON.stringify({ content }), body: JSON.stringify({ content }),
}); });
if (!r.ok) {
const text = await r.text(); const text = await r.text();
logger.warn(`dirigent: moderator send failed (${r.status}): ${text}`); let json: Record<string, unknown> | null = null;
return false; try {
json = text ? (JSON.parse(text) as Record<string, unknown>) : 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 || "<empty response>"}`;
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) { } catch (err) {
logger.warn(`dirigent: moderator send error: ${String(err)}`); const error = String(err);
return false; logger.warn(`dirigent: moderator send error channel=${channelId}: ${error}`);
return { ok: false, channelId, error };
} }
} }

View File

@@ -24,7 +24,7 @@ type BeforeMessageWriteDeps = {
channelId: string, channelId: string,
content: string, content: string,
logger: { info: (m: string) => void; warn: (m: string) => void }, logger: { info: (m: string) => void; warn: (m: string) => void },
) => Promise<void>; ) => Promise<unknown>;
discussionService?: { discussionService?: {
maybeSendIdleReminder: (channelId: string) => Promise<void>; maybeSendIdleReminder: (channelId: string) => Promise<void>;
getDiscussion: (channelId: string) => { status: string } | undefined; getDiscussion: (channelId: string) => { status: string } | undefined;

View File

@@ -22,7 +22,7 @@ type MessageSentDeps = {
channelId: string, channelId: string,
content: string, content: string,
logger: { info: (m: string) => void; warn: (m: string) => void }, logger: { info: (m: string) => void; warn: (m: string) => void },
) => Promise<void>; ) => Promise<unknown>;
}; };
export function registerMessageSentHook(deps: MessageSentDeps): void { export function registerMessageSentHook(deps: MessageSentDeps): void {