* docs: thread-bound subagents plan * docs: add exact thread-bound subagent implementation touchpoints * Docs: prioritize auto thread-bound subagent flow * Docs: add ACP harness thread-binding extensions * Discord: add thread-bound session routing and auto-bind spawn flow * Subagents: add focus commands and ACP/session binding lifecycle hooks * Tests: cover thread bindings, focus commands, and ACP unbind hooks * Docs: add plugin-hook appendix for thread-bound subagents * Plugins: add subagent lifecycle hook events * Core: emit subagent lifecycle hooks and decouple Discord bindings * Discord: handle subagent bind lifecycle via plugin hooks * Subagents: unify completion finalizer and split registry modules * Add subagent lifecycle events module * Hooks: fix subagent ended context key * Discord: share thread bindings across ESM and Jiti * Subagents: add persistent sessions_spawn mode for thread-bound sessions * Subagents: clarify thread intro and persistent completion copy * test(subagents): stabilize sessions_spawn lifecycle cleanup assertions * Discord: add thread-bound session TTL with auto-unfocus * Subagents: fail session spawns when thread bind fails * Subagents: cover thread session failure cleanup paths * Session: add thread binding TTL config and /session ttl controls * Tests: align discord reaction expectations * Agent: persist sessionFile for keyed subagent sessions * Discord: normalize imports after conflict resolution * Sessions: centralize sessionFile resolve/persist helper * Discord: harden thread-bound subagent session routing * Rebase: resolve upstream/main conflicts * Subagents: move thread binding into hooks and split bindings modules * Docs: add channel-agnostic subagent routing hook plan * Agents: decouple subagent routing from Discord * Discord: refactor thread-bound subagent flows * Subagents: prevent duplicate end hooks and orphaned failed sessions * Refactor: split subagent command and provider phases * Subagents: honor hook delivery target overrides * Discord: add thread binding kill switches and refresh plan doc * Discord: fix thread bind channel resolution * Routing: centralize account id normalization * Discord: clean up thread bindings on startup failures * Discord: add startup cleanup regression tests * Docs: add long-term thread-bound subagent architecture * Docs: split session binding plan and dedupe thread-bound doc * Subagents: add channel-agnostic session binding routing * Subagents: stabilize announce completion routing tests * Subagents: cover multi-bound completion routing * Subagents: suppress lifecycle hooks on failed thread bind * tests: fix discord provider mock typing regressions * docs/protocol: sync slash command aliases and delete param models * fix: add changelog entry for Discord thread-bound subagents (#21805) (thanks @onutc) --------- Co-authored-by: Shadow <hi@shadowing.dev>
161 lines
5.5 KiB
TypeScript
161 lines
5.5 KiB
TypeScript
import { isMessagingToolDuplicate } from "../../agents/pi-embedded-helpers.js";
|
|
import type { MessagingToolSend } from "../../agents/pi-embedded-runner.js";
|
|
import type { ReplyToMode } from "../../config/types.js";
|
|
import { normalizeTargetForProvider } from "../../infra/outbound/target-normalization.js";
|
|
import { normalizeOptionalAccountId } from "../../routing/account-id.js";
|
|
import type { OriginatingChannelType } from "../templating.js";
|
|
import type { ReplyPayload } from "../types.js";
|
|
import { extractReplyToTag } from "./reply-tags.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(
|
|
payload: ReplyPayload,
|
|
currentMessageId?: string,
|
|
): ReplyPayload {
|
|
return resolveReplyThreadingForPayload({ payload, currentMessageId });
|
|
}
|
|
|
|
export function isRenderablePayload(payload: ReplyPayload): boolean {
|
|
return Boolean(
|
|
payload.text ||
|
|
payload.mediaUrl ||
|
|
(payload.mediaUrls && payload.mediaUrls.length > 0) ||
|
|
payload.audioAsVoice ||
|
|
payload.channelData,
|
|
);
|
|
}
|
|
|
|
export function applyReplyThreading(params: {
|
|
payloads: ReplyPayload[];
|
|
replyToMode: ReplyToMode;
|
|
replyToChannel?: OriginatingChannelType;
|
|
currentMessageId?: string;
|
|
}): ReplyPayload[] {
|
|
const { payloads, replyToMode, replyToChannel, currentMessageId } = params;
|
|
const applyReplyToMode = createReplyToModeFilterForChannel(replyToMode, replyToChannel);
|
|
const implicitReplyToId = currentMessageId?.trim() || undefined;
|
|
return payloads
|
|
.map((payload) =>
|
|
resolveReplyThreadingForPayload({ payload, implicitReplyToId, currentMessageId }),
|
|
)
|
|
.filter(isRenderablePayload)
|
|
.map(applyReplyToMode);
|
|
}
|
|
|
|
export function filterMessagingToolDuplicates(params: {
|
|
payloads: ReplyPayload[];
|
|
sentTexts: string[];
|
|
}): ReplyPayload[] {
|
|
const { payloads, sentTexts } = params;
|
|
if (sentTexts.length === 0) {
|
|
return payloads;
|
|
}
|
|
return payloads.filter((payload) => !isMessagingToolDuplicate(payload.text ?? "", sentTexts));
|
|
}
|
|
|
|
export function filterMessagingToolMediaDuplicates(params: {
|
|
payloads: ReplyPayload[];
|
|
sentMediaUrls: string[];
|
|
}): ReplyPayload[] {
|
|
const { payloads, sentMediaUrls } = params;
|
|
if (sentMediaUrls.length === 0) {
|
|
return payloads;
|
|
}
|
|
const sentSet = new Set(sentMediaUrls);
|
|
return payloads.map((payload) => {
|
|
const mediaUrl = payload.mediaUrl;
|
|
const mediaUrls = payload.mediaUrls;
|
|
const stripSingle = mediaUrl && sentSet.has(mediaUrl);
|
|
const filteredUrls = mediaUrls?.filter((u) => !sentSet.has(u));
|
|
if (!stripSingle && (!mediaUrls || filteredUrls?.length === mediaUrls.length)) {
|
|
return payload; // No change
|
|
}
|
|
return {
|
|
...payload,
|
|
mediaUrl: stripSingle ? undefined : mediaUrl,
|
|
mediaUrls: filteredUrls?.length ? filteredUrls : undefined,
|
|
};
|
|
});
|
|
}
|
|
|
|
export function shouldSuppressMessagingToolReplies(params: {
|
|
messageProvider?: string;
|
|
messagingToolSentTargets?: MessagingToolSend[];
|
|
originatingTo?: string;
|
|
accountId?: string;
|
|
}): boolean {
|
|
const provider = params.messageProvider?.trim().toLowerCase();
|
|
if (!provider) {
|
|
return false;
|
|
}
|
|
const originTarget = normalizeTargetForProvider(provider, params.originatingTo);
|
|
if (!originTarget) {
|
|
return false;
|
|
}
|
|
const originAccount = normalizeOptionalAccountId(params.accountId);
|
|
const sentTargets = params.messagingToolSentTargets ?? [];
|
|
if (sentTargets.length === 0) {
|
|
return false;
|
|
}
|
|
return sentTargets.some((target) => {
|
|
if (!target?.provider) {
|
|
return false;
|
|
}
|
|
if (target.provider.trim().toLowerCase() !== provider) {
|
|
return false;
|
|
}
|
|
const targetKey = normalizeTargetForProvider(provider, target.to);
|
|
if (!targetKey) {
|
|
return false;
|
|
}
|
|
const targetAccount = normalizeOptionalAccountId(target.accountId);
|
|
if (originAccount && targetAccount && originAccount !== targetAccount) {
|
|
return false;
|
|
}
|
|
return targetKey === originTarget;
|
|
});
|
|
}
|