mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-12 20:00:46 +08:00
重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。
同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。 Made-with: Cursor
This commit is contained in:
parent
4a23b715a2
commit
dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions
1
openclaw/extensions/thread-ownership/api.ts
Normal file
1
openclaw/extensions/thread-ownership/api.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "openclaw/plugin-sdk/thread-ownership";
|
||||
171
openclaw/extensions/thread-ownership/index.test.ts
Normal file
171
openclaw/extensions/thread-ownership/index.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
147
openclaw/extensions/thread-ownership/index.ts
Normal file
147
openclaw/extensions/thread-ownership/index.ts
Normal 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;
|
||||
});
|
||||
},
|
||||
});
|
||||
28
openclaw/extensions/thread-ownership/openclaw.plugin.json
Normal file
28
openclaw/extensions/thread-ownership/openclaw.plugin.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue