mirror of
https://github.com/farcasclaudiu/openclaw.git
synced 2026-06-28 17:01:53 +03:00
refactor(reply): clarify explicit reply tags in off mode (#16189)
* refactor(reply): clarify explicit reply tags in off mode * fix(plugin-sdk): alias account-id subpath for extensions
This commit is contained in:
committed by
GitHub
parent
6f7d31c426
commit
ef70a55b7a
@@ -37,6 +37,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Outbound: add a write-ahead delivery queue with crash-recovery retries to prevent lost outbound messages after gateway restarts. (#15636) Thanks @nabbilkhan, @thewilloftheshadow.
|
- Outbound: add a write-ahead delivery queue with crash-recovery retries to prevent lost outbound messages after gateway restarts. (#15636) Thanks @nabbilkhan, @thewilloftheshadow.
|
||||||
- Auto-reply/Threading: auto-inject implicit reply threading so `replyToMode` works without requiring model-emitted `[[reply_to_current]]`, while preserving `replyToMode: "off"` behavior for implicit Slack replies and keeping block-streaming chunk coalescing stable under `replyToMode: "first"`. (#14976) Thanks @Diaspar4u.
|
- Auto-reply/Threading: auto-inject implicit reply threading so `replyToMode` works without requiring model-emitted `[[reply_to_current]]`, while preserving `replyToMode: "off"` behavior for implicit Slack replies and keeping block-streaming chunk coalescing stable under `replyToMode: "first"`. (#14976) Thanks @Diaspar4u.
|
||||||
- Auto-reply/Threading: honor explicit `[[reply_to_*]]` tags even when `replyToMode` is `off`. (#16174) Thanks @aldoeliacim.
|
- Auto-reply/Threading: honor explicit `[[reply_to_*]]` tags even when `replyToMode` is `off`. (#16174) Thanks @aldoeliacim.
|
||||||
|
- Plugins/Threading: rename `allowTagsWhenOff` to `allowExplicitReplyTagsWhenOff` and keep the old key as a deprecated alias for compatibility. (#16189)
|
||||||
- Outbound/Threading: pass `replyTo` and `threadId` from `message send` tool actions through the core outbound send path to channel adapters, preserving thread/reply routing. (#14948) Thanks @mcaxtr.
|
- Outbound/Threading: pass `replyTo` and `threadId` from `message send` tool actions through the core outbound send path to channel adapters, preserving thread/reply routing. (#14948) Thanks @mcaxtr.
|
||||||
- Auto-reply/Media: allow image-only inbound messages (no caption) to reach the agent instead of short-circuiting as empty text, and preserve thread context in queued/followup prompt bodies for media-only runs. (#11916) Thanks @arosstale.
|
- Auto-reply/Media: allow image-only inbound messages (no caption) to reach the agent instead of short-circuiting as empty text, and preserve thread context in queued/followup prompt bodies for media-only runs. (#11916) Thanks @arosstale.
|
||||||
- Discord: route autoThread replies to existing threads instead of the root channel. (#8302) Thanks @gavinbmoore, @thewilloftheshadow.
|
- Discord: route autoThread replies to existing threads instead of the root channel. (#8302) Thanks @gavinbmoore, @thewilloftheshadow.
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
|||||||
threading: {
|
threading: {
|
||||||
resolveReplyToMode: ({ cfg, accountId, chatType }) =>
|
resolveReplyToMode: ({ cfg, accountId, chatType }) =>
|
||||||
resolveSlackReplyToMode(resolveSlackAccount({ cfg, accountId }), chatType),
|
resolveSlackReplyToMode(resolveSlackAccount({ cfg, accountId }), chatType),
|
||||||
allowTagsWhenOff: true,
|
allowExplicitReplyTagsWhenOff: true,
|
||||||
buildToolContext: (params) => buildSlackThreadingToolContext(params),
|
buildToolContext: (params) => buildSlackThreadingToolContext(params),
|
||||||
},
|
},
|
||||||
messaging: {
|
messaging: {
|
||||||
|
|||||||
@@ -7,41 +7,54 @@ import { normalizeTargetForProvider } from "../../infra/outbound/target-normaliz
|
|||||||
import { extractReplyToTag } from "./reply-tags.js";
|
import { extractReplyToTag } from "./reply-tags.js";
|
||||||
import { createReplyToModeFilterForChannel } from "./reply-threading.js";
|
import { createReplyToModeFilterForChannel } from "./reply-threading.js";
|
||||||
|
|
||||||
|
function resolveReplyThreadingForPayload(params: {
|
||||||
|
payload: ReplyPayload;
|
||||||
|
implicitReplyToId?: string;
|
||||||
|
currentMessageId?: string;
|
||||||
|
}): ReplyPayload {
|
||||||
|
const implicitReplyToId = params.implicitReplyToId?.trim() || undefined;
|
||||||
|
const currentMessageId = params.currentMessageId?.trim() || undefined;
|
||||||
|
|
||||||
|
// 1) Apply implicit reply threading first (replyToMode will strip later if needed).
|
||||||
|
let resolved: ReplyPayload =
|
||||||
|
params.payload.replyToId || params.payload.replyToCurrent === false || !implicitReplyToId
|
||||||
|
? params.payload
|
||||||
|
: { ...params.payload, replyToId: implicitReplyToId };
|
||||||
|
|
||||||
|
// 2) Parse explicit reply tags from text (if present) and clean them.
|
||||||
|
if (typeof resolved.text === "string" && resolved.text.includes("[[")) {
|
||||||
|
const { cleaned, replyToId, replyToCurrent, hasTag } = extractReplyToTag(
|
||||||
|
resolved.text,
|
||||||
|
currentMessageId,
|
||||||
|
);
|
||||||
|
resolved = {
|
||||||
|
...resolved,
|
||||||
|
text: cleaned ? cleaned : undefined,
|
||||||
|
replyToId: replyToId ?? resolved.replyToId,
|
||||||
|
replyToTag: hasTag || resolved.replyToTag,
|
||||||
|
replyToCurrent: replyToCurrent || resolved.replyToCurrent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) If replyToCurrent was set out-of-band (e.g. tags already stripped upstream),
|
||||||
|
// ensure replyToId is set to the current message id when available.
|
||||||
|
if (resolved.replyToCurrent && !resolved.replyToId && currentMessageId) {
|
||||||
|
resolved = {
|
||||||
|
...resolved,
|
||||||
|
replyToId: currentMessageId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backward-compatible helper: apply explicit reply tags/directives to a single payload.
|
||||||
|
// This intentionally does not apply implicit threading.
|
||||||
export function applyReplyTagsToPayload(
|
export function applyReplyTagsToPayload(
|
||||||
payload: ReplyPayload,
|
payload: ReplyPayload,
|
||||||
currentMessageId?: string,
|
currentMessageId?: string,
|
||||||
): ReplyPayload {
|
): ReplyPayload {
|
||||||
if (typeof payload.text !== "string") {
|
return resolveReplyThreadingForPayload({ payload, currentMessageId });
|
||||||
if (!payload.replyToCurrent || payload.replyToId) {
|
|
||||||
return payload;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...payload,
|
|
||||||
replyToId: currentMessageId?.trim() || undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const shouldParseTags = payload.text.includes("[[");
|
|
||||||
if (!shouldParseTags) {
|
|
||||||
if (!payload.replyToCurrent || payload.replyToId) {
|
|
||||||
return payload;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...payload,
|
|
||||||
replyToId: currentMessageId?.trim() || undefined,
|
|
||||||
replyToTag: payload.replyToTag ?? true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const { cleaned, replyToId, replyToCurrent, hasTag } = extractReplyToTag(
|
|
||||||
payload.text,
|
|
||||||
currentMessageId,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
...payload,
|
|
||||||
text: cleaned ? cleaned : undefined,
|
|
||||||
replyToId: replyToId ?? payload.replyToId,
|
|
||||||
replyToTag: hasTag || payload.replyToTag,
|
|
||||||
replyToCurrent: replyToCurrent || payload.replyToCurrent,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isRenderablePayload(payload: ReplyPayload): boolean {
|
export function isRenderablePayload(payload: ReplyPayload): boolean {
|
||||||
@@ -64,13 +77,9 @@ export function applyReplyThreading(params: {
|
|||||||
const applyReplyToMode = createReplyToModeFilterForChannel(replyToMode, replyToChannel);
|
const applyReplyToMode = createReplyToModeFilterForChannel(replyToMode, replyToChannel);
|
||||||
const implicitReplyToId = currentMessageId?.trim() || undefined;
|
const implicitReplyToId = currentMessageId?.trim() || undefined;
|
||||||
return payloads
|
return payloads
|
||||||
.map((payload) => {
|
.map((payload) =>
|
||||||
const autoThreaded =
|
resolveReplyThreadingForPayload({ payload, implicitReplyToId, currentMessageId }),
|
||||||
payload.replyToId || payload.replyToCurrent === false || !implicitReplyToId
|
)
|
||||||
? payload
|
|
||||||
: { ...payload, replyToId: implicitReplyToId };
|
|
||||||
return applyReplyTagsToPayload(autoThreaded, currentMessageId);
|
|
||||||
})
|
|
||||||
.filter(isRenderablePayload)
|
.filter(isRenderablePayload)
|
||||||
.map(applyReplyToMode);
|
.map(applyReplyToMode);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ describe("createReplyToModeFilter", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("keeps replyToId when mode is off and reply tags are allowed", () => {
|
it("keeps replyToId when mode is off and reply tags are allowed", () => {
|
||||||
const filter = createReplyToModeFilter("off", { allowTagsWhenOff: true });
|
const filter = createReplyToModeFilter("off", { allowExplicitReplyTagsWhenOff: true });
|
||||||
expect(filter({ text: "hi", replyToId: "1", replyToTag: true }).replyToId).toBe("1");
|
expect(filter({ text: "hi", replyToId: "1", replyToTag: true }).replyToId).toBe("1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function resolveReplyToMode(
|
|||||||
|
|
||||||
export function createReplyToModeFilter(
|
export function createReplyToModeFilter(
|
||||||
mode: ReplyToMode,
|
mode: ReplyToMode,
|
||||||
opts: { allowTagsWhenOff?: boolean } = {},
|
opts: { allowExplicitReplyTagsWhenOff?: boolean } = {},
|
||||||
) {
|
) {
|
||||||
let hasThreaded = false;
|
let hasThreaded = false;
|
||||||
return (payload: ReplyPayload): ReplyPayload => {
|
return (payload: ReplyPayload): ReplyPayload => {
|
||||||
@@ -33,7 +33,8 @@ export function createReplyToModeFilter(
|
|||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
if (mode === "off") {
|
if (mode === "off") {
|
||||||
if (opts.allowTagsWhenOff && payload.replyToTag) {
|
const isExplicit = Boolean(payload.replyToTag) || Boolean(payload.replyToCurrent);
|
||||||
|
if (opts.allowExplicitReplyTagsWhenOff && isExplicit) {
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
return { ...payload, replyToId: undefined };
|
return { ...payload, replyToId: undefined };
|
||||||
@@ -54,12 +55,15 @@ export function createReplyToModeFilterForChannel(
|
|||||||
channel?: OriginatingChannelType,
|
channel?: OriginatingChannelType,
|
||||||
) {
|
) {
|
||||||
const provider = normalizeChannelId(channel);
|
const provider = normalizeChannelId(channel);
|
||||||
// Always honour explicit [[reply_to_*]] tags even when replyToMode is "off".
|
const normalized = typeof channel === "string" ? channel.trim().toLowerCase() : undefined;
|
||||||
// Per-channel opt-out is possible but the safe default is to allow them.
|
const isWebchat = normalized === "webchat";
|
||||||
const allowTagsWhenOff = provider
|
// Default: allow explicit reply tags/directives even when replyToMode is "off".
|
||||||
? (getChannelDock(provider)?.threading?.allowTagsWhenOff ?? true)
|
// Unknown channels fail closed; internal webchat stays allowed.
|
||||||
: true;
|
const dock = provider ? getChannelDock(provider) : undefined;
|
||||||
|
const allowExplicitReplyTagsWhenOff = provider
|
||||||
|
? (dock?.threading?.allowExplicitReplyTagsWhenOff ?? dock?.threading?.allowTagsWhenOff ?? true)
|
||||||
|
: isWebchat;
|
||||||
return createReplyToModeFilter(mode, {
|
return createReplyToModeFilter(mode, {
|
||||||
allowTagsWhenOff,
|
allowExplicitReplyTagsWhenOff,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -369,7 +369,7 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
|
|||||||
threading: {
|
threading: {
|
||||||
resolveReplyToMode: ({ cfg, accountId, chatType }) =>
|
resolveReplyToMode: ({ cfg, accountId, chatType }) =>
|
||||||
resolveSlackReplyToMode(resolveSlackAccount({ cfg, accountId }), chatType),
|
resolveSlackReplyToMode(resolveSlackAccount({ cfg, accountId }), chatType),
|
||||||
allowTagsWhenOff: true,
|
allowExplicitReplyTagsWhenOff: true,
|
||||||
buildToolContext: (params) => buildSlackThreadingToolContext(params),
|
buildToolContext: (params) => buildSlackThreadingToolContext(params),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -223,6 +223,16 @@ export type ChannelThreadingAdapter = {
|
|||||||
accountId?: string | null;
|
accountId?: string | null;
|
||||||
chatType?: string | null;
|
chatType?: string | null;
|
||||||
}) => "off" | "first" | "all";
|
}) => "off" | "first" | "all";
|
||||||
|
/**
|
||||||
|
* When replyToMode is "off", allow explicit reply tags/directives to keep replyToId.
|
||||||
|
*
|
||||||
|
* Default in shared reply flow: true for known providers; per-channel opt-out supported.
|
||||||
|
*/
|
||||||
|
allowExplicitReplyTagsWhenOff?: boolean;
|
||||||
|
/**
|
||||||
|
* Deprecated alias for allowExplicitReplyTagsWhenOff.
|
||||||
|
* Kept for compatibility with older extensions/docks.
|
||||||
|
*/
|
||||||
allowTagsWhenOff?: boolean;
|
allowTagsWhenOff?: boolean;
|
||||||
buildToolContext?: (params: {
|
buildToolContext?: (params: {
|
||||||
cfg: OpenClawConfig;
|
cfg: OpenClawConfig;
|
||||||
|
|||||||
+39
-2
@@ -74,6 +74,37 @@ const resolvePluginSdkAlias = (): string | null => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resolvePluginSdkAccountIdAlias = (): string | null => {
|
||||||
|
try {
|
||||||
|
const modulePath = fileURLToPath(import.meta.url);
|
||||||
|
const isProduction = process.env.NODE_ENV === "production";
|
||||||
|
const isTest = process.env.VITEST || process.env.NODE_ENV === "test";
|
||||||
|
let cursor = path.dirname(modulePath);
|
||||||
|
for (let i = 0; i < 6; i += 1) {
|
||||||
|
const srcCandidate = path.join(cursor, "src", "plugin-sdk", "account-id.ts");
|
||||||
|
const distCandidate = path.join(cursor, "dist", "plugin-sdk", "account-id.js");
|
||||||
|
const orderedCandidates = isProduction
|
||||||
|
? isTest
|
||||||
|
? [distCandidate, srcCandidate]
|
||||||
|
: [distCandidate]
|
||||||
|
: [srcCandidate, distCandidate];
|
||||||
|
for (const candidate of orderedCandidates) {
|
||||||
|
if (fs.existsSync(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const parent = path.dirname(cursor);
|
||||||
|
if (parent === cursor) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
cursor = parent;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
function buildCacheKey(params: {
|
function buildCacheKey(params: {
|
||||||
workspaceDir?: string;
|
workspaceDir?: string;
|
||||||
plugins: NormalizedPluginsConfig;
|
plugins: NormalizedPluginsConfig;
|
||||||
@@ -211,12 +242,18 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
|||||||
pushDiagnostics(registry.diagnostics, manifestRegistry.diagnostics);
|
pushDiagnostics(registry.diagnostics, manifestRegistry.diagnostics);
|
||||||
|
|
||||||
const pluginSdkAlias = resolvePluginSdkAlias();
|
const pluginSdkAlias = resolvePluginSdkAlias();
|
||||||
|
const pluginSdkAccountIdAlias = resolvePluginSdkAccountIdAlias();
|
||||||
const jiti = createJiti(import.meta.url, {
|
const jiti = createJiti(import.meta.url, {
|
||||||
interopDefault: true,
|
interopDefault: true,
|
||||||
extensions: [".ts", ".tsx", ".mts", ".cts", ".mtsx", ".ctsx", ".js", ".mjs", ".cjs", ".json"],
|
extensions: [".ts", ".tsx", ".mts", ".cts", ".mtsx", ".ctsx", ".js", ".mjs", ".cjs", ".json"],
|
||||||
...(pluginSdkAlias
|
...(pluginSdkAlias || pluginSdkAccountIdAlias
|
||||||
? {
|
? {
|
||||||
alias: { "openclaw/plugin-sdk": pluginSdkAlias },
|
alias: {
|
||||||
|
...(pluginSdkAlias ? { "openclaw/plugin-sdk": pluginSdkAlias } : {}),
|
||||||
|
...(pluginSdkAccountIdAlias
|
||||||
|
? { "openclaw/plugin-sdk/account-id": pluginSdkAccountIdAlias }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user