fix(clickclack): isolate bot loop budgets by thread

This commit is contained in:
Shakker
2026-08-09 18:12:10 +01:00
parent 5a659c624b
commit 3d80a2f6f2
4 changed files with 54 additions and 9 deletions
+3 -1
View File
@@ -469,7 +469,9 @@ Accepted bot messages also pass through OpenClaw's shared bot-pair loop guard.
Use `botLoopProtection` on the account or `channels.defaults.botLoopProtection`
to tune its window, budget, cooldown, or enabled state. Group-level `allowBots`
and `botLoopProtection` values follow the same exact-channel, wildcard, then
account-level precedence as the other group policies.
account-level precedence as the other group policies. Top-level channel
messages share a channel budget, while replies in different ClickClack threads
use independent thread-root budgets.
Older ClickClack responses may omit `author.kind`. Those messages intentionally
remain on the legacy `allowFrom` path: `allowFrom: ["*"]` can admit them, and
+16 -5
View File
@@ -53,6 +53,18 @@ type ClickClackPreparedInboundRoute = {
revoked: boolean;
};
function resolveClickClackBotLoopConversationId(params: {
message: ClickClackMessage;
isDirect: boolean;
}): string {
if (params.message.parent_message_id && params.message.thread_root_id) {
return params.message.thread_root_id;
}
return params.isDirect
? (params.message.direct_conversation_id ?? params.message.author_id)
: (params.message.channel_id ?? params.message.thread_root_id ?? params.message.author_id);
}
function resolveAccountAgentRoute(params: {
cfg: OpenClawConfig;
account: ResolvedClickClackAccount;
@@ -235,11 +247,10 @@ export async function resolveClickClackInboundAccess(params: {
// The workspace is the shared boundary; account IDs would let the
// same conversation evade the budget by alternating receivers.
scopeId: params.account.workspace,
conversationId: preparedRoute.isDirect
? (params.message.direct_conversation_id ?? params.message.author_id)
: (params.message.channel_id ??
params.message.thread_root_id ??
params.message.author_id),
conversationId: resolveClickClackBotLoopConversationId({
message: params.message,
isDirect: preparedRoute.isDirect,
}),
senderId: params.message.author_id,
receiverId: params.account.botUserId,
eventId: params.message.id,
+1 -3
View File
@@ -50,9 +50,7 @@ export function resolveClickClackBotPolicy(params: {
// account-wide override.
const groups = channelKey ? account.groups : undefined;
const wildcard = groups?.["*"];
const exact = channelKey
? Object.entries(groups ?? {}).find(([key]) => key.trim() === channelKey)?.[1]
: undefined;
const exact = channelKey ? groups?.[channelKey] : undefined;
return {
allowBots: exact?.allowBots ?? wildcard?.allowBots ?? account.allowBots ?? false,
botLoopProtection: mergePairLoopGuardConfig(
@@ -286,11 +286,45 @@ describe("ClickClack inbound mention gating", () => {
conversationId: "chn_1",
senderId: "usr_sender",
receiverId: "usr_receiver",
eventId: "msg_1",
defaultsConfig: { maxEventsPerWindow: 7 },
defaultEnabled: true,
});
});
it("isolates bot loop budgets by ClickClack thread root", async () => {
const runtime = createRuntime();
setClickClackRuntime(runtime);
const account = createAgentAccount({ allowFrom: ["usr_sender"], allowBots: true });
const author = createAuthor({ id: "usr_sender", kind: "bot", handle: "sender" });
const threadA = await resolveClickClackInboundAccess({
account,
config: {} satisfies CoreConfig,
message: createMessage({
id: "msg_thread_a_reply",
author_id: "usr_sender",
parent_message_id: "msg_thread_a",
thread_root_id: "msg_thread_a",
author,
}),
});
const threadB = await resolveClickClackInboundAccess({
account,
config: {} satisfies CoreConfig,
message: createMessage({
id: "msg_thread_b_reply",
author_id: "usr_sender",
parent_message_id: "msg_thread_b",
thread_root_id: "msg_thread_b",
author,
}),
});
expect(threadA.botLoopProtection?.conversationId).toBe("msg_thread_a");
expect(threadB.botLoopProtection?.conversationId).toBe("msg_thread_b");
});
it("does not let bot opt-in bypass the wildcard human allowFrom default", async () => {
const runtime = createRuntime();
setClickClackRuntime(runtime);