import { resolveFollowingNextRunAt } from "./automation.ts"; import { composeReplyImagePrompt, composeReplyVideoPrompt, normalizeSkipSentinel, parseStructuredReplyOutput, pickReplyMediaDirective, REPLY_OUTPUT_JSON_SCHEMA, resolveMaxMediaPromptLen, splitDiscordMessage } from "./botHelpers.ts"; import { buildAutomationPrompt, buildSystemPrompt } from "../prompts/index.ts"; import { getMediaPromptCraftGuidance } from "../prompts/promptCore.ts"; import { buildReplyToolSet, executeReplyTool } from "../tools/replyTools.ts"; import type { ReplyToolContext, ReplyToolRuntime } from "../tools/replyTools.ts"; import { sanitizeBotText, sleep } from "../utils.ts"; import { getAutomationsSettings, getBotName, getDiscoverySettings, getMemorySettings, getReplyPermissions } from "../settings/agentStack.ts"; import { buildContextContentBlocks, type ContentBlock, type ContextMessage } from "../llm/serviceShared.ts"; import type { BotContext } from "./botContext.ts"; import { loadBehavioralMemoryFacts } from "./memorySlice.ts";
const MAX_AUTOMATION_RUNS_PER_TICK = 4;
type AutomationMemorySlice = { userFacts: Array<Record<string, unknown>>; relevantFacts: Array<Record<string, unknown>>; guidanceFacts: Array<Record<string, unknown>>; };
type AutomationChannelLike = { id: string; guildId?: string; name?: string; send?: (payload: unknown) => Promise<{ id: string; createdTimestamp: number; guildId: string; channelId: string; }>; sendTyping?: () => Promise; };
type AutomationClientLike = BotContext["client"] & { user?: { id?: string; } | null; channels: { cache: { get: (id: string) => AutomationChannelLike | undefined; }; }; };
type AutomationRowLike = { id?: number; guild_id?: string; channel_id?: string; title?: string; instruction?: string; created_by_user_id?: string; next_run_at?: string | null; schedule?: Record<string, unknown>; };
type ImageBudgetLike = { canGenerate: boolean; remaining: number; };
type VideoBudgetLike = { canGenerate: boolean; remaining: number; };
type GifBudgetLike = { canFetch: boolean; remaining: number; };
type MediaCapabilitiesLike = { simpleImageReady: boolean; complexImageReady: boolean; videoReady: boolean; };
export type AutomationEngineRuntime = BotContext & { readonly client: AutomationClientLike; readonly search: ReplyToolRuntime["search"]; automationCycleRunning: boolean; isChannelAllowed: (settings: Record<string, unknown>, channelId: string) => boolean; canSendMessage: (maxPerHour: number) => boolean; canTalkNow: (settings: Record<string, unknown>) => boolean; getSimulatedTypingDelayMs: (minMs: number, jitterMs: number) => number; markSpoke: () => void; composeMessageContentForHistory: (message: unknown, baseText?: string) => string; loadFactProfile: (payload: { settings: Record<string, unknown>; userId?: string | null; guildId?: string; channelId?: string; queryText?: string; trace?: Record<string, unknown>; source?: string; }) => AutomationMemorySlice; buildMediaMemoryFacts: (payload: { userFacts: Array<Record<string, unknown>>; relevantFacts: Array<Record<string, unknown>>; }) => string[]; buildMemoryLookupContext: (payload: { settings: Record<string, unknown>; }) => Record<string, unknown>; getImageBudgetState: (settings: Record<string, unknown>) => ImageBudgetLike; getVideoGenerationBudgetState: (settings: Record<string, unknown>) => VideoBudgetLike; getGifBudgetState: (settings: Record<string, unknown>) => GifBudgetLike; getMediaGenerationCapabilities: (settings: Record<string, unknown>) => MediaCapabilitiesLike; resolveMediaAttachment: (payload: { settings: Record<string, unknown>; text: string; directive: { type: string | null; gifQuery?: string | null; imagePrompt?: string | null; complexImagePrompt?: string | null; videoPrompt?: string | null; }; trace: { guildId?: string | null; channelId?: string | null; userId?: string | null; source?: string; }; }) => Promise<{ payload: { content: string; files?: unknown[]; }; media: unknown; }>; };
function isSendableChannel( channel: AutomationChannelLike | null | undefined ): channel is AutomationChannelLike { return Boolean(channel) && typeof channel.send === "function" && typeof channel.sendTyping === "function"; }
export async function maybeRunAutomationCycle(runtime: AutomationEngineRuntime) { const settings = runtime.store.getSettings(); if (!getAutomationsSettings(settings).enabled) return; if (runtime.automationCycleRunning) return; runtime.automationCycleRunning = true;
try { const dueRows = runtime.store.claimDueAutomations({ now: new Date().toISOString(), limit: MAX_AUTOMATION_RUNS_PER_TICK }); if (!dueRows.length) return;
for (const row of dueRows) {
await runAutomationJob(runtime, row as AutomationRowLike);
}
} finally { runtime.automationCycleRunning = false; } }
async function runAutomationJob( runtime: AutomationEngineRuntime, automation: AutomationRowLike ) { const startedAt = new Date().toISOString(); const guildId = String(automation?.guild_id || "").trim(); const channelId = String(automation?.channel_id || "").trim(); const automationId = Number(automation?.id || 0); if (!guildId || !channelId || !Number.isInteger(automationId) || automationId <= 0) return;
const settings = runtime.store.getSettings(); const permissions = getReplyPermissions(settings); const botName = getBotName(settings); let status = "active"; let nextRunAt = null; let runStatus = "ok"; let summary = ""; let errorText = ""; let sentMessageId = null; let retrySoon = false;
try { if (!runtime.isChannelAllowed(settings, channelId)) { runStatus = "error"; errorText = "channel blocked by current settings"; } else if (!runtime.canSendMessage(permissions.maxMessagesPerHour)) { runStatus = "skipped"; summary = "hourly message cap hit; retrying soon"; retrySoon = true; } else if (!runtime.canTalkNow(settings)) { runStatus = "skipped"; summary = "message cooldown active; retrying soon"; retrySoon = true; } else { const channel = runtime.client.channels.cache.get(channelId); if (!isSendableChannel(channel)) { runStatus = "error"; errorText = "channel unavailable"; } else { const generationResult = await generateAutomationPayload(runtime, { automation, settings, channel });
if (generationResult.skip) {
runStatus = "skipped";
summary = generationResult.summary || "model skipped this run";
} else {
await channel.sendTyping();
await sleep(runtime.getSimulatedTypingDelayMs(350, 1100));
const autoChunks = splitDiscordMessage(generationResult.payload.content);
const autoFirstPayload = { ...generationResult.payload, content: autoChunks[0] };
const sent = await channel.send(autoFirstPayload);
for (let i = 1; i < autoChunks.length; i++) {
await channel.send({ content: autoChunks[i] });
}
sentMessageId = sent.id;
summary = generationResult.summary || "posted";
runtime.markSpoke();
runtime.store.recordMessage({
messageId: sent.id,
createdAt: sent.createdTimestamp,
guildId: sent.guildId,
channelId: sent.channelId,
authorId: runtime.client.user?.id || "unknown",
authorName: botName,
isBot: true,
content: runtime.composeMessageContentForHistory(sent, generationResult.text),
referencedMessageId: null
});
if (settings?.memory?.enabled && typeof runtime.memory?.ingestMessage === "function") {
void runtime.memory.ingestMessage({
messageId: sent.id,
authorId: runtime.client.user?.id || "unknown",
authorName: botName,
content: generationResult.text,
isBot: true,
settings,
trace: {
guildId: sent.guildId,
channelId: sent.channelId,
userId: runtime.client.user?.id || null,
source: "automation_post_memory_ingest"
}
}).catch((error) => {
runtime.store.logAction({
kind: "bot_error",
guildId: sent.guildId,
channelId: sent.channelId,
messageId: sent.id,
userId: runtime.client.user?.id || null,
content: `memory_automation_ingest: ${String(error?.message || error)}`
});
});
}
runtime.store.logAction({
kind: "automation_post",
guildId: sent.guildId,
channelId: sent.channelId,
messageId: sent.id,
userId: runtime.client.user?.id || null,
content: generationResult.text,
metadata: {
automationId,
media: generationResult.media || null,
llm: generationResult.llm || null
}
});
}
}
}
} catch (error) { runStatus = "error"; errorText = String(error instanceof Error ? error.message : error); }
if (runStatus === "error") { status = "paused"; nextRunAt = null; } else if (retrySoon) { nextRunAt = new Date(Date.now() + 5 * 60_000).toISOString(); } else { nextRunAt = resolveFollowingNextRunAt({ schedule: automation.schedule, previousNextRunAt: automation.next_run_at, runFinishedMs: Date.now() }); if (!nextRunAt) { status = "paused"; } }
const finishedAt = new Date().toISOString(); const finalized = runtime.store.finalizeAutomationRun({ automationId, guildId, status, nextRunAt, lastRunAt: finishedAt, lastError: errorText || null, lastResult: summary || (runStatus === "error" ? "error" : runStatus) }); runtime.store.recordAutomationRun({ automationId, startedAt, finishedAt, status: runStatus, summary: summary || null, error: errorText || null, messageId: sentMessageId, metadata: { nextRunAt, statusAfterRun: finalized?.status || status } });
runtime.store.logAction({
kind: runStatus === "error" ? "automation_error" : "automation_run",
guildId,
channelId,
messageId: sentMessageId,
userId: runtime.client.user?.id || null,
content:
runStatus === "error"
? automation #${automationId}: ${errorText || "run failed"}
: automation #${automationId}: ${summary || runStatus},
metadata: {
automationId,
runStatus,
statusAfterRun: finalized?.status || status,
nextRunAt
}
});
}
async function generateAutomationPayload( runtime: AutomationEngineRuntime, { automation, settings, channel }: { automation: AutomationRowLike; settings: Record<string, unknown>; channel: AutomationChannelLike; } ) { const memory = getMemorySettings(settings); const discovery = getDiscoverySettings(settings); if (!runtime.llm?.generate) { const fallback = sanitizeBotText(String(automation?.instruction || "scheduled task"), 1200); return { skip: false, summary: fallback.slice(0, 220), text: fallback, payload: { content: fallback }, media: null, llm: null }; }
const recentMessages = runtime.store.getRecentMessages(channel.id, memory.promptSlice.maxRecentMessages);
const automationOwnerId = String(automation?.created_by_user_id || "").trim() || null;
const automationQuery = ${String(automation?.title || "")} ${String(automation?.instruction || "")}
.replace(/\s+/g, " ")
.trim();
const memorySlice = runtime.loadFactProfile({
settings,
userId: automationOwnerId,
guildId: automation.guild_id,
channelId: automation.channel_id,
queryText: automationQuery,
trace: {
guildId: automation.guild_id,
channelId: automation.channel_id,
userId: automationOwnerId
},
source: "automation_run"
});
const behavioralFacts = await loadBehavioralMemoryFacts(runtime, {
settings,
guildId: automation.guild_id || "",
channelId: automation.channel_id || null,
queryText: automationQuery,
participantIds: automationOwnerId ? [automationOwnerId] : [],
trace: {
guildId: automation.guild_id,
channelId: automation.channel_id,
userId: automationOwnerId,
source: "automation_run_behavioral_memory"
},
limit: 8
});
const imageBudget = runtime.getImageBudgetState(settings); const videoBudget = runtime.getVideoGenerationBudgetState(settings); const gifBudget = runtime.getGifBudgetState(settings); const mediaCapabilities = runtime.getMediaGenerationCapabilities(settings); const mediaPromptLimit = resolveMaxMediaPromptLen(settings); const automationMediaMemoryFacts = runtime.buildMediaMemoryFacts({ userFacts: memorySlice.userFacts, relevantFacts: memorySlice.relevantFacts }); const memoryLookup = runtime.buildMemoryLookupContext({ settings }); const promptBase = { instruction: automation.instruction, channelName: channel.name || "channel", recentMessages, userFacts: memorySlice.userFacts, relevantFacts: memorySlice.relevantFacts, guidanceFacts: memorySlice.guidanceFacts, behavioralFacts, allowSimpleImagePosts: discovery.allowImagePosts && mediaCapabilities.simpleImageReady && imageBudget.canGenerate, allowComplexImagePosts: discovery.allowImagePosts && mediaCapabilities.complexImageReady && imageBudget.canGenerate, allowVideoPosts: discovery.allowVideoPosts && mediaCapabilities.videoReady && videoBudget.canGenerate, allowGifs: discovery.allowReplyGifs && gifBudget.canFetch, remainingImages: imageBudget.remaining, remainingVideos: videoBudget.remaining, remainingGifs: gifBudget.remaining, maxMediaPromptChars: mediaPromptLimit, mediaPromptCraftGuidance: getMediaPromptCraftGuidance(settings) }; const userPrompt = buildAutomationPrompt({ ...promptBase, memoryLookup }); const automationSystemPrompt = buildSystemPrompt(settings);
const automationTrace = {
guildId: automation.guild_id,
channelId: automation.channel_id,
userId: runtime.client.user?.id || null,
source: "automation_run",
event: automation:${automation.id},
reason: null,
messageId: null
};
const automationReplyTools = buildReplyToolSet(settings, {
webSearchAvailable: false,
webScrapeAvailable: false,
browserBrowseAvailable: false,
memoryAvailable: memory.enabled,
imageLookupAvailable: false
});
const automationToolRuntime: ReplyToolRuntime = {
search: runtime.search,
memory: runtime.memory,
store: runtime.store
};
const automationToolContext: ReplyToolContext = {
settings,
guildId: automation.guild_id || "",
channelId: automation.channel_id || "",
userId: runtime.client.user?.id || "",
sourceMessageId: automation:${automation.id},
sourceText: String(automation.instruction || ""),
botUserId: runtime.client.user?.id || undefined,
trace: automationTrace
};
let automationContextMessages: ContextMessage[] = []; let generation = await runtime.llm.generate({ settings, systemPrompt: automationSystemPrompt, userPrompt, contextMessages: automationContextMessages, jsonSchema: automationReplyTools.length ? "" : REPLY_OUTPUT_JSON_SCHEMA, tools: automationReplyTools, trace: automationTrace });
const AUTOMATION_TOOL_LOOP_MAX_STEPS = 2; const AUTOMATION_TOOL_LOOP_MAX_CALLS = 3; let automationToolLoopSteps = 0; let automationTotalToolCalls = 0;
while ( generation.toolCalls?.length > 0 && automationToolLoopSteps < AUTOMATION_TOOL_LOOP_MAX_STEPS && automationTotalToolCalls < AUTOMATION_TOOL_LOOP_MAX_CALLS ) { const assistantContent = buildContextContentBlocks(generation.rawContent, generation.text); automationContextMessages = [ ...automationContextMessages, { role: "user", content: userPrompt }, { role: "assistant", content: assistantContent } ];
const toolResultMessages: ContentBlock[] = [];
for (const toolCall of generation.toolCalls) {
if (automationTotalToolCalls >= AUTOMATION_TOOL_LOOP_MAX_CALLS) break;
automationTotalToolCalls += 1;
const result = await executeReplyTool(
toolCall.name,
toolCall.input as Record<string, unknown>,
automationToolRuntime,
automationToolContext
);
toolResultMessages.push({
type: "tool_result",
tool_use_id: toolCall.id,
content: result.content
});
}
automationContextMessages = [
...automationContextMessages,
{ role: "user", content: toolResultMessages }
];
generation = await runtime.llm.generate({
settings,
systemPrompt: automationSystemPrompt,
userPrompt: "",
contextMessages: automationContextMessages,
jsonSchema: "",
tools: automationReplyTools,
trace: {
...automationTrace,
event: `automation:${automation.id}:tool_loop:${automationToolLoopSteps + 1}`,
reason: null,
messageId: null
}
});
automationToolLoopSteps += 1;
}
const directive = parseStructuredReplyOutput(generation.text, mediaPromptLimit);
if (directive.parseState === "unstructured") { return { skip: true, summary: "invalid structured output; skipped run", text: "", payload: null, media: null, llm: { provider: generation.provider, model: generation.model, usage: generation.usage, costUsd: generation.costUsd } }; }
let finalText = sanitizeBotText(normalizeSkipSentinel(directive.text || ""), 1200); if (!finalText) { finalText = sanitizeBotText(String(automation.instruction || "scheduled task"), 1200); }
if (finalText === "[SKIP]") { return { skip: true, summary: "model skipped run", text: "", payload: null, media: null, llm: { provider: generation.provider, model: generation.model, usage: generation.usage, costUsd: generation.costUsd } }; }
const mediaDirective = pickReplyMediaDirective(directive); const mediaAttachment = await runtime.resolveMediaAttachment({ settings, text: finalText, directive: { type: mediaDirective?.type ?? null, gifQuery: directive.gifQuery, imagePrompt: mediaDirective?.type === "image_simple" && directive.imagePrompt ? composeReplyImagePrompt( directive.imagePrompt, finalText, mediaPromptLimit, automationMediaMemoryFacts ) : null, complexImagePrompt: mediaDirective?.type === "image_complex" && directive.complexImagePrompt ? composeReplyImagePrompt( directive.complexImagePrompt, finalText, mediaPromptLimit, automationMediaMemoryFacts ) : null, videoPrompt: mediaDirective?.type === "video" && directive.videoPrompt ? composeReplyVideoPrompt( directive.videoPrompt, finalText, mediaPromptLimit, automationMediaMemoryFacts ) : null }, trace: { guildId: automation.guild_id, channelId: automation.channel_id, userId: runtime.client.user?.id || null, source: "automation_run" } });
return { skip: false, summary: finalText.slice(0, 220), text: finalText, payload: mediaAttachment.payload, media: mediaAttachment.media, llm: { provider: generation.provider, model: generation.model, usage: generation.usage, costUsd: generation.costUsd } }; }
