mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:00:43 +00:00
Add opt-in reaction tool tracking
This commit is contained in:
committed by
Peter Steinberger
parent
c40f89414c
commit
788cff1df4
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user