Two fixes: 1. Cross-context decoration (e.g., '[from +19257864429]' prefix) was being added to ALL messages sent to a different target, even when the agent was just composing a new message via the message tool. This decoration should only be applied when forwarding/relaying messages between chats. Fix: Added skipCrossContextDecoration flag to ChannelThreadingToolContext. The message tool now sets this flag to true, so direct sends don't get decorated. The buildCrossContextDecoration function checks this flag and returns null when set. 2. Aborted requests were still completing because the abort signal wasn't being passed through the message tool execution chain. Fix: Added abortSignal propagation from message tool → runMessageAction → executeSendAction → sendMessage → deliverOutboundPayloads. Added abort checks at key points in the chain to fail fast when aborted. Files changed: - src/channels/plugins/types.core.ts: Added skipCrossContextDecoration field - src/infra/outbound/outbound-policy.ts: Check skip flag before decorating - src/agents/tools/message-tool.ts: Set skip flag, accept and pass abort signal - src/infra/outbound/message-action-runner.ts: Pass abort signal through - src/infra/outbound/outbound-send-service.ts: Check and pass abort signal - src/infra/outbound/message.ts: Pass abort signal to delivery
171 lines
5.7 KiB
TypeScript
171 lines
5.7 KiB
TypeScript
import { normalizeTargetForProvider } from "./target-normalization.js";
|
|
import type {
|
|
ChannelId,
|
|
ChannelMessageActionName,
|
|
ChannelThreadingToolContext,
|
|
} from "../../channels/plugins/types.js";
|
|
import type { ClawdbotConfig } from "../../config/config.js";
|
|
import { getChannelMessageAdapter } from "./channel-adapters.js";
|
|
import { formatTargetDisplay, lookupDirectoryDisplay } from "./target-resolver.js";
|
|
|
|
export type CrossContextDecoration = {
|
|
prefix: string;
|
|
suffix: string;
|
|
embeds?: unknown[];
|
|
};
|
|
|
|
const CONTEXT_GUARDED_ACTIONS = new Set<ChannelMessageActionName>([
|
|
"send",
|
|
"poll",
|
|
"reply",
|
|
"sendWithEffect",
|
|
"sendAttachment",
|
|
"thread-create",
|
|
"thread-reply",
|
|
"sticker",
|
|
]);
|
|
|
|
const CONTEXT_MARKER_ACTIONS = new Set<ChannelMessageActionName>([
|
|
"send",
|
|
"poll",
|
|
"reply",
|
|
"sendWithEffect",
|
|
"sendAttachment",
|
|
"thread-reply",
|
|
"sticker",
|
|
]);
|
|
|
|
function resolveContextGuardTarget(
|
|
action: ChannelMessageActionName,
|
|
params: Record<string, unknown>,
|
|
): string | undefined {
|
|
if (!CONTEXT_GUARDED_ACTIONS.has(action)) return undefined;
|
|
|
|
if (action === "thread-reply" || action === "thread-create") {
|
|
if (typeof params.channelId === "string") return params.channelId;
|
|
if (typeof params.to === "string") return params.to;
|
|
return undefined;
|
|
}
|
|
|
|
if (typeof params.to === "string") return params.to;
|
|
if (typeof params.channelId === "string") return params.channelId;
|
|
return undefined;
|
|
}
|
|
|
|
function normalizeTarget(channel: ChannelId, raw: string): string | undefined {
|
|
return normalizeTargetForProvider(channel, raw) ?? raw.trim().toLowerCase();
|
|
}
|
|
|
|
function isCrossContextTarget(params: {
|
|
channel: ChannelId;
|
|
target: string;
|
|
toolContext?: ChannelThreadingToolContext;
|
|
}): boolean {
|
|
const currentTarget = params.toolContext?.currentChannelId?.trim();
|
|
if (!currentTarget) return false;
|
|
const normalizedTarget = normalizeTarget(params.channel, params.target);
|
|
const normalizedCurrent = normalizeTarget(params.channel, currentTarget);
|
|
if (!normalizedTarget || !normalizedCurrent) return false;
|
|
return normalizedTarget !== normalizedCurrent;
|
|
}
|
|
|
|
export function enforceCrossContextPolicy(params: {
|
|
channel: ChannelId;
|
|
action: ChannelMessageActionName;
|
|
args: Record<string, unknown>;
|
|
toolContext?: ChannelThreadingToolContext;
|
|
cfg: ClawdbotConfig;
|
|
}): void {
|
|
const currentTarget = params.toolContext?.currentChannelId?.trim();
|
|
if (!currentTarget) return;
|
|
if (!CONTEXT_GUARDED_ACTIONS.has(params.action)) return;
|
|
|
|
if (params.cfg.tools?.message?.allowCrossContextSend) return;
|
|
|
|
const currentProvider = params.toolContext?.currentChannelProvider;
|
|
const allowWithinProvider =
|
|
params.cfg.tools?.message?.crossContext?.allowWithinProvider !== false;
|
|
const allowAcrossProviders =
|
|
params.cfg.tools?.message?.crossContext?.allowAcrossProviders === true;
|
|
|
|
if (currentProvider && currentProvider !== params.channel) {
|
|
if (!allowAcrossProviders) {
|
|
throw new Error(
|
|
`Cross-context messaging denied: action=${params.action} target provider "${params.channel}" while bound to "${currentProvider}".`,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (allowWithinProvider) return;
|
|
|
|
const target = resolveContextGuardTarget(params.action, params.args);
|
|
if (!target) return;
|
|
|
|
if (!isCrossContextTarget({ channel: params.channel, target, toolContext: params.toolContext })) {
|
|
return;
|
|
}
|
|
|
|
throw new Error(
|
|
`Cross-context messaging denied: action=${params.action} target="${target}" while bound to "${currentTarget}" (channel=${params.channel}).`,
|
|
);
|
|
}
|
|
|
|
export async function buildCrossContextDecoration(params: {
|
|
cfg: ClawdbotConfig;
|
|
channel: ChannelId;
|
|
target: string;
|
|
toolContext?: ChannelThreadingToolContext;
|
|
accountId?: string | null;
|
|
}): Promise<CrossContextDecoration | null> {
|
|
if (!params.toolContext?.currentChannelId) return null;
|
|
// Skip decoration for direct tool sends (agent composing, not forwarding)
|
|
if (params.toolContext.skipCrossContextDecoration) return null;
|
|
if (!isCrossContextTarget(params)) return null;
|
|
|
|
const markerConfig = params.cfg.tools?.message?.crossContext?.marker;
|
|
if (markerConfig?.enabled === false) return null;
|
|
|
|
const currentName =
|
|
(await lookupDirectoryDisplay({
|
|
cfg: params.cfg,
|
|
channel: params.channel,
|
|
targetId: params.toolContext.currentChannelId,
|
|
accountId: params.accountId ?? undefined,
|
|
})) ?? params.toolContext.currentChannelId;
|
|
// Don't force group formatting here; currentChannelId can be a DM or a group.
|
|
const originLabel = formatTargetDisplay({
|
|
channel: params.channel,
|
|
target: params.toolContext.currentChannelId,
|
|
display: currentName,
|
|
});
|
|
const prefixTemplate = markerConfig?.prefix ?? "[from {channel}] ";
|
|
const suffixTemplate = markerConfig?.suffix ?? "";
|
|
const prefix = prefixTemplate.replaceAll("{channel}", originLabel);
|
|
const suffix = suffixTemplate.replaceAll("{channel}", originLabel);
|
|
|
|
const adapter = getChannelMessageAdapter(params.channel);
|
|
const embeds = adapter.supportsEmbeds
|
|
? (adapter.buildCrossContextEmbeds?.(originLabel) ?? undefined)
|
|
: undefined;
|
|
|
|
return { prefix, suffix, embeds };
|
|
}
|
|
|
|
export function shouldApplyCrossContextMarker(action: ChannelMessageActionName): boolean {
|
|
return CONTEXT_MARKER_ACTIONS.has(action);
|
|
}
|
|
|
|
export function applyCrossContextDecoration(params: {
|
|
message: string;
|
|
decoration: CrossContextDecoration;
|
|
preferEmbeds: boolean;
|
|
}): { message: string; embeds?: unknown[]; usedEmbeds: boolean } {
|
|
const useEmbeds = params.preferEmbeds && params.decoration.embeds?.length;
|
|
if (useEmbeds) {
|
|
return { message: params.message, embeds: params.decoration.embeds, usedEmbeds: true };
|
|
}
|
|
const message = `${params.decoration.prefix}${params.message}${params.decoration.suffix}`;
|
|
return { message, usedEmbeds: false };
|
|
}
|