重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。

同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。

Made-with: Cursor
This commit is contained in:
oliver 2026-04-26 08:34:33 +08:00
parent 4a23b715a2
commit dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions

View file

@ -0,0 +1 @@
export * from "openclaw/plugin-sdk/thread-ownership";

View file

@ -0,0 +1,171 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawPluginApi } from "./api.js";
import register from "./index.js";
describe("thread-ownership plugin", () => {
const hooks: Record<string, Function> = {};
const fetchMock = vi.fn() as unknown as typeof globalThis.fetch;
const api = {
pluginConfig: {},
config: {
agents: {
list: [{ id: "test-agent", default: true, identity: { name: "TestBot" } }],
},
},
id: "thread-ownership",
name: "Thread Ownership",
logger: { info: vi.fn(), warn: vi.fn(), debug: vi.fn() },
on: vi.fn((hookName: string, handler: Function) => {
hooks[hookName] = handler;
}),
};
beforeEach(() => {
vi.clearAllMocks();
for (const key of Object.keys(hooks)) {
delete hooks[key];
}
process.env.SLACK_FORWARDER_URL = "http://localhost:8750";
process.env.SLACK_BOT_USER_ID = "U999";
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
delete process.env.SLACK_FORWARDER_URL;
delete process.env.SLACK_BOT_USER_ID;
vi.restoreAllMocks();
});
describe("message_sending", () => {
beforeEach(() => {
register.register(api as unknown as OpenClawPluginApi);
});
async function sendSlackThreadMessage() {
return await hooks.message_sending(
{ content: "hello", metadata: { threadTs: "1234.5678", channelId: "C123" }, to: "C123" },
{ channelId: "slack", conversationId: "C123" },
);
}
it("allows non-slack channels", async () => {
const result = await hooks.message_sending(
{ content: "hello", metadata: { threadTs: "1234.5678", channelId: "C123" }, to: "C123" },
{ channelId: "discord", conversationId: "C123" },
);
expect(result).toBeUndefined();
expect(globalThis.fetch).not.toHaveBeenCalled();
});
it("allows top-level messages (no threadTs)", async () => {
const result = await hooks.message_sending(
{ content: "hello", metadata: {}, to: "C123" },
{ channelId: "slack", conversationId: "C123" },
);
expect(result).toBeUndefined();
expect(globalThis.fetch).not.toHaveBeenCalled();
});
it("claims ownership successfully", async () => {
vi.mocked(globalThis.fetch).mockResolvedValue(
new Response(JSON.stringify({ owner: "test-agent" }), { status: 200 }),
);
const result = await sendSlackThreadMessage();
expect(result).toBeUndefined();
expect(globalThis.fetch).toHaveBeenCalledWith(
"http://localhost:8750/api/v1/ownership/C123/1234.5678",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ agent_id: "test-agent" }),
}),
);
});
it("cancels when thread owned by another agent", async () => {
vi.mocked(globalThis.fetch).mockResolvedValue(
new Response(JSON.stringify({ owner: "other-agent" }), { status: 409 }),
);
const result = await sendSlackThreadMessage();
expect(result).toEqual({ cancel: true });
expect(api.logger.info).toHaveBeenCalledWith(expect.stringContaining("cancelled send"));
});
it("fails open on network error", async () => {
vi.mocked(globalThis.fetch).mockRejectedValue(new Error("ECONNREFUSED"));
const result = await sendSlackThreadMessage();
expect(result).toBeUndefined();
expect(api.logger.warn).toHaveBeenCalledWith(
expect.stringContaining("ownership check failed"),
);
});
});
describe("message_received @-mention tracking", () => {
beforeEach(() => {
register.register(api as unknown as OpenClawPluginApi);
});
it("tracks @-mentions and skips ownership check for mentioned threads", async () => {
// Simulate receiving a message that @-mentions the agent.
await hooks.message_received(
{ content: "Hey @TestBot help me", metadata: { threadTs: "9999.0001", channelId: "C456" } },
{ channelId: "slack", conversationId: "C456" },
);
// Now send in the same thread -- should skip the ownership HTTP call.
const result = await hooks.message_sending(
{ content: "Sure!", metadata: { threadTs: "9999.0001", channelId: "C456" }, to: "C456" },
{ channelId: "slack", conversationId: "C456" },
);
expect(result).toBeUndefined();
expect(globalThis.fetch).not.toHaveBeenCalled();
});
it("ignores @-mentions on non-slack channels", async () => {
// Use a unique thread key so module-level state from other tests doesn't interfere.
await hooks.message_received(
{ content: "Hey @TestBot", metadata: { threadTs: "7777.0001", channelId: "C999" } },
{ channelId: "discord", conversationId: "C999" },
);
// The mention should not have been tracked, so sending should still call fetch.
vi.mocked(globalThis.fetch).mockResolvedValue(
new Response(JSON.stringify({ owner: "test-agent" }), { status: 200 }),
);
await hooks.message_sending(
{ content: "Sure!", metadata: { threadTs: "7777.0001", channelId: "C999" }, to: "C999" },
{ channelId: "slack", conversationId: "C999" },
);
expect(globalThis.fetch).toHaveBeenCalled();
});
it("tracks bot user ID mentions via <@U999> syntax", async () => {
await hooks.message_received(
{ content: "Hey <@U999> help", metadata: { threadTs: "8888.0001", channelId: "C789" } },
{ channelId: "slack", conversationId: "C789" },
);
const result = await hooks.message_sending(
{ content: "On it!", metadata: { threadTs: "8888.0001", channelId: "C789" }, to: "C789" },
{ channelId: "slack", conversationId: "C789" },
);
expect(result).toBeUndefined();
expect(globalThis.fetch).not.toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,147 @@
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import {
definePluginEntry,
fetchWithSsrFGuard,
ssrfPolicyFromDangerouslyAllowPrivateNetwork,
type OpenClawConfig,
type OpenClawPluginApi,
} from "./api.js";
type ThreadOwnershipConfig = {
forwarderUrl?: string;
abTestChannels?: string[];
};
type AgentEntry = NonNullable<NonNullable<OpenClawConfig["agents"]>["list"]>[number];
type ThreadOwnershipMessageSendingResult = { cancel: true } | undefined;
// In-memory set of {channel}:{thread} keys where this agent was @-mentioned.
// Entries expire after 5 minutes.
const mentionedThreads = new Map<string, number>();
const MENTION_TTL_MS = 5 * 60 * 1000;
function cleanExpiredMentions(): void {
const now = Date.now();
for (const [key, ts] of mentionedThreads) {
if (now - ts > MENTION_TTL_MS) {
mentionedThreads.delete(key);
}
}
}
function resolveOwnershipAgent(config: OpenClawConfig): { id: string; name: string } {
const list = Array.isArray(config.agents?.list)
? config.agents.list.filter(
(entry): entry is AgentEntry => entry !== null && typeof entry === "object",
)
: [];
const selected = list.find((entry) => entry.default === true) ?? list[0];
const id = normalizeOptionalString(selected?.id) ?? "unknown";
const identityName = normalizeOptionalString(selected?.identity?.name) ?? "";
const fallbackName = normalizeOptionalString(selected?.name) ?? "";
const name = identityName || fallbackName;
return { id, name };
}
export default definePluginEntry({
id: "thread-ownership",
name: "Thread Ownership",
description: "Slack thread claim coordination for multi-agent setups",
register(api: OpenClawPluginApi) {
const pluginCfg = (api.pluginConfig ?? {}) as ThreadOwnershipConfig;
const forwarderUrl = (
pluginCfg.forwarderUrl ??
process.env.SLACK_FORWARDER_URL ??
"http://slack-forwarder:8750"
).replace(/\/$/, "");
const abTestChannels = new Set(
pluginCfg.abTestChannels ??
process.env.THREAD_OWNERSHIP_CHANNELS?.split(",").filter(Boolean) ??
[],
);
const { id: agentId, name: agentName } = resolveOwnershipAgent(api.config);
const botUserId = process.env.SLACK_BOT_USER_ID ?? "";
api.on("message_received", async (event, ctx) => {
if (ctx.channelId !== "slack") {
return;
}
const text = event.content ?? "";
const threadTs = (event.metadata?.threadTs as string) ?? "";
const channelId = (event.metadata?.channelId as string) ?? ctx.conversationId ?? "";
if (!threadTs || !channelId) {
return;
}
const mentioned =
(agentName && text.includes(`@${agentName}`)) ||
(botUserId && text.includes(`<@${botUserId}>`));
if (mentioned) {
cleanExpiredMentions();
mentionedThreads.set(`${channelId}:${threadTs}`, Date.now());
}
});
api.on("message_sending", async (event, ctx): Promise<ThreadOwnershipMessageSendingResult> => {
if (ctx.channelId !== "slack") {
return undefined;
}
const threadTs = (event.metadata?.threadTs as string) ?? "";
const channelId = (event.metadata?.channelId as string) ?? event.to;
if (!threadTs) {
return undefined;
}
if (abTestChannels.size > 0 && !abTestChannels.has(channelId)) {
return undefined;
}
cleanExpiredMentions();
if (mentionedThreads.has(`${channelId}:${threadTs}`)) {
return undefined;
}
try {
// The forwarder is an internal service (e.g. a Docker container); allow private-network
// access but pin DNS so DNS-rebinding attacks cannot pivot to a different internal host.
const { response: resp, release } = await fetchWithSsrFGuard({
url: `${forwarderUrl}/api/v1/ownership/${channelId}/${threadTs}`,
init: {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ agent_id: agentId }),
},
timeoutMs: 3000,
policy: ssrfPolicyFromDangerouslyAllowPrivateNetwork(true),
auditContext: "thread-ownership",
});
try {
if (resp.ok) {
return undefined;
}
if (resp.status === 409) {
const body = (await resp.json()) as { owner?: string };
api.logger.info?.(
`thread-ownership: cancelled send to ${channelId}:${threadTs} — owned by ${body.owner}`,
);
return { cancel: true };
}
api.logger.warn?.(`thread-ownership: unexpected status ${resp.status}, allowing send`);
} finally {
await release();
}
} catch (err) {
api.logger.warn?.(
`thread-ownership: ownership check failed (${String(err)}), allowing send`,
);
}
return undefined;
});
},
});

View file

@ -0,0 +1,28 @@
{
"id": "thread-ownership",
"name": "Thread Ownership",
"description": "Prevents multiple agents from responding in the same Slack thread. Uses HTTP calls to the slack-forwarder ownership API.",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"forwarderUrl": {
"type": "string"
},
"abTestChannels": {
"type": "array",
"items": { "type": "string" }
}
}
},
"uiHints": {
"forwarderUrl": {
"label": "Forwarder URL",
"help": "Base URL of the slack-forwarder ownership API (default: http://slack-forwarder:8750)"
},
"abTestChannels": {
"label": "A/B Test Channels",
"help": "Slack channel IDs where thread ownership is enforced"
}
}
}