Add opt-in reaction tool tracking

This commit is contained in:
Peter Steinberger
2026-05-03 15:39:02 +01:00
committed by Peter Steinberger
parent c40f89414c
commit 788cff1df4
10 changed files with 168 additions and 22 deletions

View File

@@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai
### Changes
- Agents/tools: skip optional media and PDF tool factories when the effective tool denylist already blocks them, avoiding unnecessary hot-path setup for tools that will be filtered out before model use. (#76773) Thanks @dorukardahan.
- Discord/status: let explicit reaction tool calls opt into tracking subsequent tool progress on the reacted message with `trackToolCalls: true`, and use the shared tool display emoji table for status reactions.
- Gateway/performance: lazy-load early runtime discovery and shutdown-hook helpers, defer maintenance timers until after readiness, and trim duplicate plugin auto-enable work during Gateway startup.
- QA/Mantis: add a `pnpm openclaw qa mantis discord-smoke` runner and manual GitHub workflow that verify the Mantis Discord bot can see the configured guild/channel, post a smoke message, add a reaction, and upload artifacts.
- Gateway/performance: lazy-load the heavy cron runtime after the rest of Gateway startup, defer restart-sentinel refresh after readiness, and let the Gateway startup benchmark write per-run V8 CPU profiles with `--cpu-prof-dir`.

View File

@@ -22,6 +22,9 @@ tool with the `react` action. Reaction behavior varies by channel and transport.
- `emoji` is required when adding a reaction.
- Set `emoji` to an empty string (`""`) to remove the bot's reaction(s).
- Set `remove: true` to remove a specific emoji (requires non-empty `emoji`).
- On channels that support status reactions, `trackToolCalls: true` on a
reaction lets the runtime use that reacted message for subsequent tool
progress reactions during the same turn.
## Channel behavior

View File

@@ -1,4 +1,4 @@
import { DEFAULT_EMOJIS } from "openclaw/plugin-sdk/channel-feedback";
import { DEFAULT_EMOJIS, DEFAULT_TIMING } from "openclaw/plugin-sdk/channel-feedback";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-dispatch-runtime";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { DiscordMessagePreflightContext } from "./message-handler.preflight.js";
@@ -87,7 +87,11 @@ type DispatchInboundParams = {
replyOptions?: {
onReasoningStream?: () => Promise<void> | void;
onReasoningEnd?: () => Promise<void> | void;
onToolStart?: (payload: { name?: string }) => Promise<void> | void;
onToolStart?: (payload: {
name?: string;
phase?: string;
args?: Record<string, unknown>;
}) => Promise<void> | void;
onItemEvent?: (payload: {
progressText?: string;
summary?: string;
@@ -585,7 +589,7 @@ describe("processDiscordMessage ack reactions", () => {
it("debounces intermediate phase reactions and jumps to done for short runs", async () => {
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onReasoningStream?.();
await params?.replyOptions?.onToolStart?.({ name: "exec" });
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
return createNoQueuedDispatchResult();
});
@@ -600,6 +604,39 @@ describe("processDiscordMessage ack reactions", () => {
expect(emojis).not.toContain(DEFAULT_EMOJIS.coding);
});
it("can bind status reactions to an explicitly tracked reaction target", async () => {
vi.useFakeTimers();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onToolStart?.({
name: "message",
phase: "start",
args: {
action: "react",
channelId: "c1",
messageId: "m1",
emoji: "📈",
trackToolCalls: true,
},
});
await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs);
return createNoQueuedDispatchResult();
});
const ctx = await createAutomaticSourceDeliveryContext({
cfg: { messages: { ackReaction: "👀" } },
});
await runProcessDiscordMessage(ctx);
await vi.runAllTimersAsync();
const calls = sendMocks.reactMessageDiscord.mock.calls as unknown as Array<
[string, string, string]
>;
expect(calls).toContainEqual(expect.arrayContaining(["c1", "m1", "📈"]));
expect(calls).toContainEqual(expect.arrayContaining(["c1", "m1", "✉️"]));
expect(calls).toContainEqual(expect.arrayContaining(["c1", "m1", DEFAULT_EMOJIS.done]));
});
it("shows stall emojis for long no-progress runs", async () => {
vi.useFakeTimers();
let releaseDispatch!: () => void;

View File

@@ -82,6 +82,21 @@ type DiscordMessageProcessObserver = {
onReplyPlanResolved?: (params: { createdThreadId?: string; sessionKey?: string }) => void;
};
type ToolStartPayload = {
name?: string;
phase?: string;
args?: Record<string, unknown>;
};
function readToolStringArg(args: Record<string, unknown>, key: string): string | undefined {
const value = args[key];
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function readToolBooleanArg(args: Record<string, unknown>, key: string): boolean {
return args[key] === true;
}
export async function processDiscordMessage(
ctx: DiscordMessagePreflightContext,
observer?: DiscordMessageProcessObserver,
@@ -203,7 +218,9 @@ export async function processDiscordMessage(
messageId: message.id,
reactionContext: ackReactionContext,
});
const statusReactions = createStatusReactionController({
let statusReactionTarget = `${messageChannelId}/${message.id}`;
let statusReactionsActive = statusReactionsEnabled;
let statusReactions = createStatusReactionController({
enabled: statusReactionsEnabled,
adapter: discordAdapter,
initialEmoji: ackReaction,
@@ -213,11 +230,67 @@ export async function processDiscordMessage(
logAckFailure({
log: logVerbose,
channel: "discord",
target: `${messageChannelId}/${message.id}`,
target: statusReactionTarget,
error: err,
});
},
});
const maybeBindStatusReactionsToToolReaction = (payload: ToolStartPayload) => {
if (
sourceRepliesAreToolOnly ||
cfg.messages?.statusReactions?.enabled === false ||
payload.phase !== "start" ||
payload.name !== "message" ||
!payload.args
) {
return;
}
const args = payload.args;
const action = readToolStringArg(args, "action")?.toLowerCase();
if (action !== "react") {
return;
}
const shouldTrack =
readToolBooleanArg(args, "trackToolCalls") || readToolBooleanArg(args, "track_tool_calls");
if (!shouldTrack) {
return;
}
const emoji = readToolStringArg(args, "emoji");
const remove = readToolBooleanArg(args, "remove");
if (!emoji || remove) {
return;
}
const trackedMessageId =
readToolStringArg(args, "messageId") ?? readToolStringArg(args, "message_id") ?? message.id;
const trackedChannelId =
readToolStringArg(args, "channelId") ?? readToolStringArg(args, "to") ?? messageChannelId;
statusReactionTarget = `${trackedChannelId}/${trackedMessageId}`;
if (statusReactionsActive) {
void statusReactions.clear();
}
const trackedAdapter = createDiscordAckReactionAdapter({
channelId: trackedChannelId,
messageId: trackedMessageId,
reactionContext: ackReactionContext,
});
statusReactions = createStatusReactionController({
enabled: true,
adapter: trackedAdapter,
initialEmoji: emoji,
emojis: cfg.messages?.statusReactions?.emojis,
timing: cfg.messages?.statusReactions?.timing,
onError: (err) => {
logAckFailure({
log: logVerbose,
channel: "discord",
target: statusReactionTarget,
error: err,
});
},
});
statusReactionsActive = true;
void statusReactions.setQueued();
};
queueInitialDiscordAckReaction({
enabled: statusReactionsEnabled,
shouldSendAckReaction,
@@ -546,6 +619,7 @@ export async function processDiscordMessage(
if (isProcessAborted(abortSignal)) {
return;
}
maybeBindStatusReactionsToToolReaction(payload);
await statusReactions.setTool(payload.name);
draftPreview.pushToolProgress(
payload.name ? `tool: ${payload.name}` : "tool running",
@@ -632,7 +706,7 @@ export async function processDiscordMessage(
markDispatchIdle();
}
}
if (statusReactionsEnabled) {
if (statusReactionsActive) {
if (dispatchAborted) {
if (removeAckAfterReply) {
void statusReactions.clear();

View File

@@ -649,7 +649,12 @@ export function handleToolExecutionStart(
// Best-effort typing signal; do not block tool summaries on slow emitters.
void ctx.params.onAgentEvent?.({
stream: "tool",
data: { phase: "start", name: toolName, toolCallId },
data: {
phase: "start",
name: toolName,
toolCallId,
args: sanitizeToolArgs(args) as Record<string, unknown>,
},
});
if (isExecToolName(toolName)) {

View File

@@ -180,6 +180,17 @@ function buildReactionSchema() {
),
emoji: Type.Optional(Type.String()),
remove: Type.Optional(Type.Boolean()),
trackToolCalls: Type.Optional(
Type.Boolean({
description:
"When true for a reaction to the current inbound message, use that reacted message as the status-reaction target for subsequent tool progress when the channel supports it.",
}),
),
track_tool_calls: Type.Optional(
Type.Boolean({
description: "snake_case alias of trackToolCalls.",
}),
),
targetAuthor: Type.Optional(Type.String()),
targetAuthorUuid: Type.Optional(Type.String()),
groupId: Type.Optional(Type.String()),

View File

@@ -81,7 +81,11 @@ export type GetReplyOptions = {
onBlockReply?: (payload: ReplyPayload, context?: BlockReplyContext) => Promise<void> | void;
onToolResult?: (payload: ReplyPayload) => Promise<void> | void;
/** Called when a tool phase starts/updates, before summary payloads are emitted. */
onToolStart?: (payload: { name?: string; phase?: string }) => Promise<void> | void;
onToolStart?: (payload: {
name?: string;
phase?: string;
args?: Record<string, unknown>;
}) => Promise<void> | void;
/** Called when a concrete work item starts, updates, or completes. */
onItemEvent?: (payload: {
itemId?: string;

View File

@@ -1528,7 +1528,14 @@ export async function runAgentTurnWithFallback(params: {
const name = readStringValue(evt.data.name);
if (phase === "start" || phase === "update") {
await params.typingSignals.signalToolStart();
await params.opts?.onToolStart?.({ name, phase });
await params.opts?.onToolStart?.({
name,
phase,
args:
evt.data.args && typeof evt.data.args === "object"
? (evt.data.args as Record<string, unknown>)
: undefined,
});
}
}
if (evt.stream === "item") {

View File

@@ -78,18 +78,19 @@ function expectObjectHasKeys(value: Record<string, unknown>, keys: readonly stri
describe("resolveToolEmoji", () => {
it.each([
{ name: "returns coding emoji for exec tool", tool: "exec", expected: DEFAULT_EMOJIS.coding },
{ name: "returns display emoji for exec tool", tool: "exec", expected: "🛠️" },
{
name: "returns coding emoji for process tool",
name: "returns display emoji for process tool",
tool: "process",
expected: DEFAULT_EMOJIS.coding,
expected: "🧰",
},
{
name: "returns web emoji for web_search tool",
name: "returns display emoji for web_search tool",
tool: "web_search",
expected: DEFAULT_EMOJIS.web,
expected: "🔎",
},
{ name: "returns web emoji for browser tool", tool: "browser", expected: DEFAULT_EMOJIS.web },
{ name: "returns display emoji for browser tool", tool: "browser", expected: "🌐" },
{ name: "returns display emoji for message tool", tool: "message", expected: "✉️" },
{
name: "returns tool emoji for unknown tool",
tool: "unknown_tool",
@@ -97,7 +98,7 @@ describe("resolveToolEmoji", () => {
},
{ name: "returns tool emoji for empty string", tool: "", expected: DEFAULT_EMOJIS.tool },
{ name: "returns tool emoji for undefined", tool: undefined, expected: DEFAULT_EMOJIS.tool },
{ name: "is case-insensitive", tool: "EXEC", expected: DEFAULT_EMOJIS.coding },
{ name: "is case-insensitive", tool: "EXEC", expected: "🛠️" },
{
name: "matches tokens within tool names",
tool: "my_exec_wrapper",
@@ -174,7 +175,7 @@ describe("createStatusReactionController", () => {
void controller.setTool("exec");
await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs);
expectSetEmojiCall(calls, DEFAULT_EMOJIS.coding);
expectSetEmojiCall(calls, "🛠️");
});
const immediateTerminalCases = [
@@ -244,9 +245,9 @@ describe("createStatusReactionController", () => {
void controller.setTool("exec");
await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs);
// Should only have the last one (exec → coding)
// Should only have the last one (exec → display emoji)
const setEmojis = calls.filter((c) => c.method === "set").map((c) => c.emoji);
expect(setEmojis).toEqual([DEFAULT_EMOJIS.coding]);
expect(setEmojis).toEqual(["🛠️"]);
});
it("should deduplicate same emoji calls", async () => {
@@ -307,9 +308,7 @@ describe("createStatusReactionController", () => {
await controller.setDone();
const removeEmojis = calls.filter((call) => call.method === "remove").map((call) => call.emoji);
expect(removeEmojis).toEqual(
expect.arrayContaining(["👀", DEFAULT_EMOJIS.thinking, DEFAULT_EMOJIS.coding]),
);
expect(removeEmojis).toEqual(expect.arrayContaining(["👀", DEFAULT_EMOJIS.thinking, "🛠️"]));
expect(removeEmojis).not.toContain(DEFAULT_EMOJIS.done);
});

View File

@@ -1,3 +1,5 @@
import { TOOL_DISPLAY_CONFIG } from "../agents/tool-display-config.js";
import { resolveToolDisplay } from "../agents/tool-display.js";
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
/**
@@ -108,6 +110,9 @@ export function resolveToolEmoji(
if (!normalized) {
return emojis.tool;
}
if (Object.hasOwn(TOOL_DISPLAY_CONFIG.tools, normalized)) {
return resolveToolDisplay({ name: toolName }).emoji;
}
if (WEB_TOOL_TOKENS.some((token) => normalized.includes(token))) {
return emojis.web;
}