refactor(auto-reply): split reply pipeline
This commit is contained in:
473
src/auto-reply/reply/get-reply-directives.ts
Normal file
473
src/auto-reply/reply/get-reply-directives.ts
Normal file
@@ -0,0 +1,473 @@
|
||||
import type { ModelAliasIndex } from "../../agents/model-selection.js";
|
||||
import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import type { SessionEntry } from "../../config/sessions.js";
|
||||
import {
|
||||
listChatCommands,
|
||||
shouldHandleTextCommands,
|
||||
} from "../commands-registry.js";
|
||||
import type { MsgContext, TemplateContext } from "../templating.js";
|
||||
import type {
|
||||
ElevatedLevel,
|
||||
ReasoningLevel,
|
||||
ThinkLevel,
|
||||
VerboseLevel,
|
||||
} from "../thinking.js";
|
||||
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
||||
import { resolveBlockStreamingChunking } from "./block-streaming.js";
|
||||
import { buildCommandContext } from "./commands.js";
|
||||
import {
|
||||
type InlineDirectives,
|
||||
parseInlineDirectives,
|
||||
} from "./directive-handling.js";
|
||||
import { applyInlineDirectiveOverrides } from "./get-reply-directives-apply.js";
|
||||
import { clearInlineDirectives } from "./get-reply-directives-utils.js";
|
||||
import {
|
||||
defaultGroupActivation,
|
||||
resolveGroupRequireMention,
|
||||
} from "./groups.js";
|
||||
import {
|
||||
CURRENT_MESSAGE_MARKER,
|
||||
stripMentions,
|
||||
stripStructuralPrefixes,
|
||||
} from "./mentions.js";
|
||||
import {
|
||||
createModelSelectionState,
|
||||
resolveContextTokens,
|
||||
} from "./model-selection.js";
|
||||
import {
|
||||
formatElevatedUnavailableMessage,
|
||||
resolveElevatedPermissions,
|
||||
} from "./reply-elevated.js";
|
||||
import { stripInlineStatus } from "./reply-inline.js";
|
||||
import type { TypingController } from "./typing.js";
|
||||
|
||||
type AgentDefaults = NonNullable<ClawdbotConfig["agents"]>["defaults"];
|
||||
|
||||
export type ReplyDirectiveContinuation = {
|
||||
commandSource: string;
|
||||
command: ReturnType<typeof buildCommandContext>;
|
||||
allowTextCommands: boolean;
|
||||
directives: InlineDirectives;
|
||||
cleanedBody: string;
|
||||
messageProviderKey: string;
|
||||
elevatedEnabled: boolean;
|
||||
elevatedAllowed: boolean;
|
||||
elevatedFailures: Array<{ gate: string; key: string }>;
|
||||
defaultActivation: ReturnType<typeof defaultGroupActivation>;
|
||||
resolvedThinkLevel: ThinkLevel | undefined;
|
||||
resolvedVerboseLevel: VerboseLevel | undefined;
|
||||
resolvedReasoningLevel: ReasoningLevel;
|
||||
resolvedElevatedLevel: ElevatedLevel;
|
||||
blockStreamingEnabled: boolean;
|
||||
blockReplyChunking?: {
|
||||
minChars: number;
|
||||
maxChars: number;
|
||||
breakPreference: "paragraph" | "newline" | "sentence";
|
||||
};
|
||||
resolvedBlockStreamingBreak: "text_end" | "message_end";
|
||||
provider: string;
|
||||
model: string;
|
||||
modelState: Awaited<ReturnType<typeof createModelSelectionState>>;
|
||||
contextTokens: number;
|
||||
inlineStatusRequested: boolean;
|
||||
directiveAck?: ReplyPayload;
|
||||
perMessageQueueMode?: InlineDirectives["queueMode"];
|
||||
perMessageQueueOptions?: {
|
||||
debounceMs?: number;
|
||||
cap?: number;
|
||||
dropPolicy?: InlineDirectives["dropPolicy"];
|
||||
};
|
||||
};
|
||||
|
||||
export type ReplyDirectiveResult =
|
||||
| { kind: "reply"; reply: ReplyPayload | ReplyPayload[] | undefined }
|
||||
| { kind: "continue"; result: ReplyDirectiveContinuation };
|
||||
|
||||
export async function resolveReplyDirectives(params: {
|
||||
ctx: MsgContext;
|
||||
cfg: ClawdbotConfig;
|
||||
agentId: string;
|
||||
agentDir: string;
|
||||
agentCfg: AgentDefaults;
|
||||
sessionCtx: TemplateContext;
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
sessionKey: string;
|
||||
storePath?: string;
|
||||
sessionScope: Parameters<
|
||||
typeof applyInlineDirectiveOverrides
|
||||
>[0]["sessionScope"];
|
||||
groupResolution: Parameters<
|
||||
typeof resolveGroupRequireMention
|
||||
>[0]["groupResolution"];
|
||||
isGroup: boolean;
|
||||
triggerBodyNormalized: string;
|
||||
commandAuthorized: boolean;
|
||||
defaultProvider: string;
|
||||
defaultModel: string;
|
||||
aliasIndex: ModelAliasIndex;
|
||||
provider: string;
|
||||
model: string;
|
||||
typing: TypingController;
|
||||
opts?: GetReplyOptions;
|
||||
}): Promise<ReplyDirectiveResult> {
|
||||
const {
|
||||
ctx,
|
||||
cfg,
|
||||
agentId,
|
||||
agentCfg,
|
||||
agentDir,
|
||||
sessionCtx,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
sessionScope,
|
||||
groupResolution,
|
||||
isGroup,
|
||||
triggerBodyNormalized,
|
||||
commandAuthorized,
|
||||
defaultProvider,
|
||||
defaultModel,
|
||||
provider: initialProvider,
|
||||
model: initialModel,
|
||||
typing,
|
||||
opts,
|
||||
} = params;
|
||||
let provider = initialProvider;
|
||||
let model = initialModel;
|
||||
|
||||
// Prefer CommandBody/RawBody (clean message without structural context) for directive parsing.
|
||||
// Keep `Body`/`BodyStripped` as the best-available prompt text (may include context).
|
||||
const commandSource =
|
||||
sessionCtx.CommandBody ??
|
||||
sessionCtx.RawBody ??
|
||||
sessionCtx.BodyStripped ??
|
||||
sessionCtx.Body ??
|
||||
"";
|
||||
const command = buildCommandContext({
|
||||
ctx,
|
||||
cfg,
|
||||
agentId,
|
||||
sessionKey,
|
||||
isGroup,
|
||||
triggerBodyNormalized,
|
||||
commandAuthorized,
|
||||
});
|
||||
const allowTextCommands = shouldHandleTextCommands({
|
||||
cfg,
|
||||
surface: command.surface,
|
||||
commandSource: ctx.CommandSource,
|
||||
});
|
||||
const reservedCommands = new Set(
|
||||
listChatCommands().flatMap((cmd) =>
|
||||
cmd.textAliases.map((a) => a.replace(/^\//, "").toLowerCase()),
|
||||
),
|
||||
);
|
||||
const configuredAliases = Object.values(cfg.agents?.defaults?.models ?? {})
|
||||
.map((entry) => entry.alias?.trim())
|
||||
.filter((alias): alias is string => Boolean(alias))
|
||||
.filter((alias) => !reservedCommands.has(alias.toLowerCase()));
|
||||
const allowStatusDirective = allowTextCommands && command.isAuthorizedSender;
|
||||
let parsedDirectives = parseInlineDirectives(commandSource, {
|
||||
modelAliases: configuredAliases,
|
||||
allowStatusDirective,
|
||||
});
|
||||
const hasInlineStatus =
|
||||
parsedDirectives.hasStatusDirective &&
|
||||
parsedDirectives.cleaned.trim().length > 0;
|
||||
if (hasInlineStatus) {
|
||||
parsedDirectives = {
|
||||
...parsedDirectives,
|
||||
hasStatusDirective: false,
|
||||
};
|
||||
}
|
||||
if (
|
||||
isGroup &&
|
||||
ctx.WasMentioned !== true &&
|
||||
parsedDirectives.hasElevatedDirective
|
||||
) {
|
||||
if (parsedDirectives.elevatedLevel !== "off") {
|
||||
parsedDirectives = {
|
||||
...parsedDirectives,
|
||||
hasElevatedDirective: false,
|
||||
elevatedLevel: undefined,
|
||||
rawElevatedLevel: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
const hasInlineDirective =
|
||||
parsedDirectives.hasThinkDirective ||
|
||||
parsedDirectives.hasVerboseDirective ||
|
||||
parsedDirectives.hasReasoningDirective ||
|
||||
parsedDirectives.hasElevatedDirective ||
|
||||
parsedDirectives.hasModelDirective ||
|
||||
parsedDirectives.hasQueueDirective;
|
||||
if (hasInlineDirective) {
|
||||
const stripped = stripStructuralPrefixes(parsedDirectives.cleaned);
|
||||
const noMentions = isGroup
|
||||
? stripMentions(stripped, ctx, cfg, agentId)
|
||||
: stripped;
|
||||
if (noMentions.trim().length > 0) {
|
||||
const directiveOnlyCheck = parseInlineDirectives(noMentions, {
|
||||
modelAliases: configuredAliases,
|
||||
});
|
||||
if (directiveOnlyCheck.cleaned.trim().length > 0) {
|
||||
const allowInlineStatus =
|
||||
parsedDirectives.hasStatusDirective &&
|
||||
allowTextCommands &&
|
||||
command.isAuthorizedSender;
|
||||
parsedDirectives = allowInlineStatus
|
||||
? {
|
||||
...clearInlineDirectives(parsedDirectives.cleaned),
|
||||
hasStatusDirective: true,
|
||||
}
|
||||
: clearInlineDirectives(parsedDirectives.cleaned);
|
||||
}
|
||||
}
|
||||
}
|
||||
let directives = commandAuthorized
|
||||
? parsedDirectives
|
||||
: {
|
||||
...parsedDirectives,
|
||||
hasThinkDirective: false,
|
||||
hasVerboseDirective: false,
|
||||
hasReasoningDirective: false,
|
||||
hasStatusDirective: false,
|
||||
hasModelDirective: false,
|
||||
hasQueueDirective: false,
|
||||
queueReset: false,
|
||||
};
|
||||
const existingBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? "";
|
||||
let cleanedBody = (() => {
|
||||
if (!existingBody) return parsedDirectives.cleaned;
|
||||
if (!sessionCtx.CommandBody && !sessionCtx.RawBody) {
|
||||
return parseInlineDirectives(existingBody, {
|
||||
modelAliases: configuredAliases,
|
||||
allowStatusDirective,
|
||||
}).cleaned;
|
||||
}
|
||||
|
||||
const markerIndex = existingBody.indexOf(CURRENT_MESSAGE_MARKER);
|
||||
if (markerIndex < 0) {
|
||||
return parseInlineDirectives(existingBody, {
|
||||
modelAliases: configuredAliases,
|
||||
allowStatusDirective,
|
||||
}).cleaned;
|
||||
}
|
||||
|
||||
const head = existingBody.slice(
|
||||
0,
|
||||
markerIndex + CURRENT_MESSAGE_MARKER.length,
|
||||
);
|
||||
const tail = existingBody.slice(
|
||||
markerIndex + CURRENT_MESSAGE_MARKER.length,
|
||||
);
|
||||
const cleanedTail = parseInlineDirectives(tail, {
|
||||
modelAliases: configuredAliases,
|
||||
allowStatusDirective,
|
||||
}).cleaned;
|
||||
return `${head}${cleanedTail}`;
|
||||
})();
|
||||
|
||||
if (allowStatusDirective) {
|
||||
cleanedBody = stripInlineStatus(cleanedBody).cleaned;
|
||||
}
|
||||
|
||||
sessionCtx.Body = cleanedBody;
|
||||
sessionCtx.BodyStripped = cleanedBody;
|
||||
|
||||
const messageProviderKey =
|
||||
sessionCtx.Provider?.trim().toLowerCase() ??
|
||||
ctx.Provider?.trim().toLowerCase() ??
|
||||
"";
|
||||
const elevated = resolveElevatedPermissions({
|
||||
cfg,
|
||||
agentId,
|
||||
ctx,
|
||||
provider: messageProviderKey,
|
||||
});
|
||||
const elevatedEnabled = elevated.enabled;
|
||||
const elevatedAllowed = elevated.allowed;
|
||||
const elevatedFailures = elevated.failures;
|
||||
if (
|
||||
directives.hasElevatedDirective &&
|
||||
(!elevatedEnabled || !elevatedAllowed)
|
||||
) {
|
||||
typing.cleanup();
|
||||
const runtimeSandboxed = resolveSandboxRuntimeStatus({
|
||||
cfg,
|
||||
sessionKey: ctx.SessionKey,
|
||||
}).sandboxed;
|
||||
return {
|
||||
kind: "reply",
|
||||
reply: {
|
||||
text: formatElevatedUnavailableMessage({
|
||||
runtimeSandboxed,
|
||||
failures: elevatedFailures,
|
||||
sessionKey: ctx.SessionKey,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const requireMention = resolveGroupRequireMention({
|
||||
cfg,
|
||||
ctx: sessionCtx,
|
||||
groupResolution,
|
||||
});
|
||||
const defaultActivation = defaultGroupActivation(requireMention);
|
||||
const resolvedThinkLevel =
|
||||
(directives.thinkLevel as ThinkLevel | undefined) ??
|
||||
(sessionEntry?.thinkingLevel as ThinkLevel | undefined) ??
|
||||
(agentCfg?.thinkingDefault as ThinkLevel | undefined);
|
||||
|
||||
const resolvedVerboseLevel =
|
||||
(directives.verboseLevel as VerboseLevel | undefined) ??
|
||||
(sessionEntry?.verboseLevel as VerboseLevel | undefined) ??
|
||||
(agentCfg?.verboseDefault as VerboseLevel | undefined);
|
||||
const resolvedReasoningLevel: ReasoningLevel =
|
||||
(directives.reasoningLevel as ReasoningLevel | undefined) ??
|
||||
(sessionEntry?.reasoningLevel as ReasoningLevel | undefined) ??
|
||||
"off";
|
||||
const resolvedElevatedLevel = elevatedAllowed
|
||||
? ((directives.elevatedLevel as ElevatedLevel | undefined) ??
|
||||
(sessionEntry?.elevatedLevel as ElevatedLevel | undefined) ??
|
||||
(agentCfg?.elevatedDefault as ElevatedLevel | undefined) ??
|
||||
"on")
|
||||
: "off";
|
||||
const resolvedBlockStreaming =
|
||||
opts?.disableBlockStreaming === true
|
||||
? "off"
|
||||
: opts?.disableBlockStreaming === false
|
||||
? "on"
|
||||
: agentCfg?.blockStreamingDefault === "on"
|
||||
? "on"
|
||||
: "off";
|
||||
const resolvedBlockStreamingBreak: "text_end" | "message_end" =
|
||||
agentCfg?.blockStreamingBreak === "message_end"
|
||||
? "message_end"
|
||||
: "text_end";
|
||||
const blockStreamingEnabled =
|
||||
resolvedBlockStreaming === "on" && opts?.disableBlockStreaming !== true;
|
||||
const blockReplyChunking = blockStreamingEnabled
|
||||
? resolveBlockStreamingChunking(
|
||||
cfg,
|
||||
sessionCtx.Provider,
|
||||
sessionCtx.AccountId,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const modelState = await createModelSelectionState({
|
||||
cfg,
|
||||
agentCfg,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
defaultProvider,
|
||||
defaultModel,
|
||||
provider,
|
||||
model,
|
||||
hasModelDirective: directives.hasModelDirective,
|
||||
});
|
||||
provider = modelState.provider;
|
||||
model = modelState.model;
|
||||
|
||||
let contextTokens = resolveContextTokens({
|
||||
agentCfg,
|
||||
model,
|
||||
});
|
||||
|
||||
const initialModelLabel = `${provider}/${model}`;
|
||||
const formatModelSwitchEvent = (label: string, alias?: string) =>
|
||||
alias
|
||||
? `Model switched to ${alias} (${label}).`
|
||||
: `Model switched to ${label}.`;
|
||||
const isModelListAlias =
|
||||
directives.hasModelDirective &&
|
||||
["status", "list"].includes(
|
||||
directives.rawModelDirective?.trim().toLowerCase() ?? "",
|
||||
);
|
||||
const effectiveModelDirective = isModelListAlias
|
||||
? undefined
|
||||
: directives.rawModelDirective;
|
||||
|
||||
const inlineStatusRequested =
|
||||
hasInlineStatus && allowTextCommands && command.isAuthorizedSender;
|
||||
|
||||
const applyResult = await applyInlineDirectiveOverrides({
|
||||
ctx,
|
||||
cfg,
|
||||
agentId,
|
||||
agentDir,
|
||||
agentCfg,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
sessionScope,
|
||||
isGroup,
|
||||
allowTextCommands,
|
||||
command,
|
||||
directives,
|
||||
messageProviderKey,
|
||||
elevatedEnabled,
|
||||
elevatedAllowed,
|
||||
elevatedFailures,
|
||||
defaultProvider,
|
||||
defaultModel,
|
||||
aliasIndex: params.aliasIndex,
|
||||
provider,
|
||||
model,
|
||||
modelState,
|
||||
initialModelLabel,
|
||||
formatModelSwitchEvent,
|
||||
resolvedElevatedLevel,
|
||||
defaultActivation: () => defaultActivation,
|
||||
contextTokens,
|
||||
effectiveModelDirective,
|
||||
typing,
|
||||
});
|
||||
if (applyResult.kind === "reply") {
|
||||
return { kind: "reply", reply: applyResult.reply };
|
||||
}
|
||||
directives = applyResult.directives;
|
||||
provider = applyResult.provider;
|
||||
model = applyResult.model;
|
||||
contextTokens = applyResult.contextTokens;
|
||||
const { directiveAck, perMessageQueueMode, perMessageQueueOptions } =
|
||||
applyResult;
|
||||
|
||||
return {
|
||||
kind: "continue",
|
||||
result: {
|
||||
commandSource,
|
||||
command,
|
||||
allowTextCommands,
|
||||
directives,
|
||||
cleanedBody,
|
||||
messageProviderKey,
|
||||
elevatedEnabled,
|
||||
elevatedAllowed,
|
||||
elevatedFailures,
|
||||
defaultActivation,
|
||||
resolvedThinkLevel,
|
||||
resolvedVerboseLevel,
|
||||
resolvedReasoningLevel,
|
||||
resolvedElevatedLevel,
|
||||
blockStreamingEnabled,
|
||||
blockReplyChunking,
|
||||
resolvedBlockStreamingBreak,
|
||||
provider,
|
||||
model,
|
||||
modelState,
|
||||
contextTokens,
|
||||
inlineStatusRequested,
|
||||
directiveAck,
|
||||
perMessageQueueMode,
|
||||
perMessageQueueOptions,
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user