import { runSubagentAnnounceFlow } from "../../agents/subagent-announce.js"; import { countActiveDescendantRuns } from "../../agents/subagent-registry.js"; import { SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; import { createOutboundSendDeps, type CliDeps } from "../../cli/outbound-send-deps.js"; import type { OpenClawConfig } from "../../config/config.js"; import { resolveAgentMainSessionKey } from "../../config/sessions.js"; import { deliverOutboundPayloads } from "../../infra/outbound/deliver.js"; import { resolveAgentOutboundIdentity } from "../../infra/outbound/identity.js"; import { resolveOutboundSessionRoute } from "../../infra/outbound/outbound-session.js"; import { buildOutboundSessionContext } from "../../infra/outbound/session-context.js"; import { logWarn } from "../../logger.js"; import type { CronJob, CronRunTelemetry } from "../types.js"; import type { DeliveryTargetResolution } from "./delivery-target.js"; import { pickSummaryFromOutput } from "./helpers.js"; import type { RunCronAgentTurnResult } from "./run.js"; import { expectsSubagentFollowup, isLikelyInterimCronMessage, readDescendantSubagentFallbackReply, waitForDescendantSubagentSummary, } from "./subagent-followup.js"; export function matchesMessagingToolDeliveryTarget( target: { provider?: string; to?: string; accountId?: string }, delivery: { channel?: string; to?: string; accountId?: string }, ): boolean { if (!delivery.channel || !delivery.to || !target.to) { return false; } const channel = delivery.channel.trim().toLowerCase(); const provider = target.provider?.trim().toLowerCase(); if (provider && provider !== "message" && provider !== channel) { return false; } if (target.accountId && delivery.accountId && target.accountId !== delivery.accountId) { return false; } // Strip :topic:NNN suffix from target.to before comparing — the cron delivery.to // is already stripped to chatId only, but the agent's message tool may pass a // topic-qualified target (e.g. "-1003597428309:topic:462"). const normalizedTargetTo = target.to.replace(/:topic:\d+$/, ""); return normalizedTargetTo === delivery.to; } export function resolveCronDeliveryBestEffort(job: CronJob): boolean { if (typeof job.delivery?.bestEffort === "boolean") { return job.delivery.bestEffort; } if (job.payload.kind === "agentTurn" && typeof job.payload.bestEffortDeliver === "boolean") { return job.payload.bestEffortDeliver; } return false; } async function resolveCronAnnounceSessionKey(params: { cfg: OpenClawConfig; agentId: string; fallbackSessionKey: string; delivery: { channel: NonNullable; to?: string; accountId?: string; threadId?: string | number; }; }): Promise { const to = params.delivery.to?.trim(); if (!to) { return params.fallbackSessionKey; } try { const route = await resolveOutboundSessionRoute({ cfg: params.cfg, channel: params.delivery.channel, agentId: params.agentId, accountId: params.delivery.accountId, target: to, threadId: params.delivery.threadId, }); const resolved = route?.sessionKey?.trim(); if (resolved) { return resolved; } } catch { // Fall back to main session routing if announce session resolution fails. } return params.fallbackSessionKey; } export type SuccessfulDeliveryTarget = Extract; type DispatchCronDeliveryParams = { cfg: OpenClawConfig; cfgWithAgentDefaults: OpenClawConfig; deps: CliDeps; job: CronJob; agentId: string; agentSessionKey: string; runSessionId: string; runStartedAt: number; runEndedAt: number; timeoutMs: number; resolvedDelivery: DeliveryTargetResolution; deliveryRequested: boolean; skipHeartbeatDelivery: boolean; skipMessagingToolDelivery: boolean; deliveryBestEffort: boolean; deliveryPayloadHasStructuredContent: boolean; deliveryPayloads: ReplyPayload[]; synthesizedText?: string; summary?: string; outputText?: string; telemetry?: CronRunTelemetry; abortSignal?: AbortSignal; isAborted: () => boolean; abortReason: () => string; withRunSession: ( result: Omit, ) => RunCronAgentTurnResult; }; export type DispatchCronDeliveryState = { result?: RunCronAgentTurnResult; delivered: boolean; deliveryAttempted: boolean; summary?: string; outputText?: string; synthesizedText?: string; deliveryPayloads: ReplyPayload[]; }; export async function dispatchCronDelivery( params: DispatchCronDeliveryParams, ): Promise { let summary = params.summary; let outputText = params.outputText; let synthesizedText = params.synthesizedText; let deliveryPayloads = params.deliveryPayloads; // `true` means we confirmed at least one outbound send reached the target. // Keep this strict so timer fallback can safely decide whether to wake main. let delivered = params.skipMessagingToolDelivery; let deliveryAttempted = params.skipMessagingToolDelivery; const failDeliveryTarget = (error: string) => params.withRunSession({ status: "error", error, errorKind: "delivery-target", summary, outputText, deliveryAttempted, ...params.telemetry, }); const deliverViaDirect = async ( delivery: SuccessfulDeliveryTarget, ): Promise => { const identity = resolveAgentOutboundIdentity(params.cfgWithAgentDefaults, params.agentId); try { const payloadsForDelivery = deliveryPayloads.length > 0 ? deliveryPayloads : synthesizedText ? [{ text: synthesizedText }] : []; if (payloadsForDelivery.length === 0) { return null; } if (params.isAborted()) { return params.withRunSession({ status: "error", error: params.abortReason(), deliveryAttempted, ...params.telemetry, }); } deliveryAttempted = true; const deliverySession = buildOutboundSessionContext({ cfg: params.cfgWithAgentDefaults, agentId: params.agentId, sessionKey: params.agentSessionKey, }); const deliveryResults = await deliverOutboundPayloads({ cfg: params.cfgWithAgentDefaults, channel: delivery.channel, to: delivery.to, accountId: delivery.accountId, threadId: delivery.threadId, payloads: payloadsForDelivery, session: deliverySession, identity, bestEffort: params.deliveryBestEffort, deps: createOutboundSendDeps(params.deps), abortSignal: params.abortSignal, }); delivered = deliveryResults.length > 0; return null; } catch (err) { if (!params.deliveryBestEffort) { return params.withRunSession({ status: "error", summary, outputText, error: String(err), deliveryAttempted, ...params.telemetry, }); } return null; } }; const deliverViaAnnounce = async ( delivery: SuccessfulDeliveryTarget, ): Promise => { if (!synthesizedText) { return null; } const announceMainSessionKey = resolveAgentMainSessionKey({ cfg: params.cfg, agentId: params.agentId, }); const announceSessionKey = await resolveCronAnnounceSessionKey({ cfg: params.cfgWithAgentDefaults, agentId: params.agentId, fallbackSessionKey: announceMainSessionKey, delivery: { channel: delivery.channel, to: delivery.to, accountId: delivery.accountId, threadId: delivery.threadId, }, }); const taskLabel = typeof params.job.name === "string" && params.job.name.trim() ? params.job.name.trim() : `cron:${params.job.id}`; const initialSynthesizedText = synthesizedText.trim(); let activeSubagentRuns = countActiveDescendantRuns(params.agentSessionKey); const expectedSubagentFollowup = expectsSubagentFollowup(initialSynthesizedText); const hadActiveDescendants = activeSubagentRuns > 0; if (activeSubagentRuns > 0 || expectedSubagentFollowup) { let finalReply = await waitForDescendantSubagentSummary({ sessionKey: params.agentSessionKey, initialReply: initialSynthesizedText, timeoutMs: params.timeoutMs, observedActiveDescendants: activeSubagentRuns > 0 || expectedSubagentFollowup, }); activeSubagentRuns = countActiveDescendantRuns(params.agentSessionKey); if ( !finalReply && activeSubagentRuns === 0 && (hadActiveDescendants || expectedSubagentFollowup) ) { finalReply = await readDescendantSubagentFallbackReply({ sessionKey: params.agentSessionKey, runStartedAt: params.runStartedAt, }); } if (finalReply && activeSubagentRuns === 0) { outputText = finalReply; summary = pickSummaryFromOutput(finalReply) ?? summary; synthesizedText = finalReply; deliveryPayloads = [{ text: finalReply }]; } } if (activeSubagentRuns > 0) { // Parent orchestration is still in progress; avoid announcing a partial // update to the main requester. return params.withRunSession({ status: "ok", summary, outputText, ...params.telemetry }); } if ( (hadActiveDescendants || expectedSubagentFollowup) && synthesizedText.trim() === initialSynthesizedText && isLikelyInterimCronMessage(initialSynthesizedText) && initialSynthesizedText.toUpperCase() !== SILENT_REPLY_TOKEN.toUpperCase() ) { // Descendants existed but no post-orchestration synthesis arrived, so // suppress stale parent text like "on it, pulling everything together". return params.withRunSession({ status: "ok", summary, outputText, ...params.telemetry }); } if (synthesizedText.toUpperCase() === SILENT_REPLY_TOKEN.toUpperCase()) { return params.withRunSession({ status: "ok", summary, outputText, delivered: true, ...params.telemetry, }); } try { if (params.isAborted()) { return params.withRunSession({ status: "error", error: params.abortReason(), deliveryAttempted, ...params.telemetry, }); } deliveryAttempted = true; const didAnnounce = await runSubagentAnnounceFlow({ childSessionKey: params.agentSessionKey, childRunId: `${params.job.id}:${params.runSessionId}:${params.runStartedAt}`, requesterSessionKey: announceSessionKey, requesterOrigin: { channel: delivery.channel, to: delivery.to, accountId: delivery.accountId, threadId: delivery.threadId, }, requesterDisplayKey: announceSessionKey, task: taskLabel, timeoutMs: params.timeoutMs, cleanup: params.job.deleteAfterRun ? "delete" : "keep", roundOneReply: synthesizedText, // Cron output is a finished completion message: send it directly to the // target channel via the completion-direct-send path rather than injecting // a trigger message into the (likely idle) main agent session. expectsCompletionMessage: true, // Keep delivery outcome truthful for cron state: if outbound send fails, // announce flow must report false so caller can apply best-effort policy. bestEffortDeliver: false, waitForCompletion: false, startedAt: params.runStartedAt, endedAt: params.runEndedAt, outcome: { status: "ok" }, announceType: "cron job", signal: params.abortSignal, }); if (didAnnounce) { delivered = true; } else { // Announce delivery failed but the agent execution itself succeeded. // Return ok so the job isn't penalized for a transient delivery issue // (e.g. "pairing required" when no active client session exists). // Delivery failure is tracked separately via delivered/deliveryAttempted. const message = "cron announce delivery failed"; logWarn(`[cron:${params.job.id}] ${message}`); if (!params.deliveryBestEffort) { return params.withRunSession({ status: "ok", summary, outputText, error: message, delivered: false, deliveryAttempted, ...params.telemetry, }); } } } catch (err) { // Same as above: announce delivery errors should not mark a successful // agent execution as failed. logWarn(`[cron:${params.job.id}] ${String(err)}`); if (!params.deliveryBestEffort) { return params.withRunSession({ status: "ok", summary, outputText, error: String(err), delivered: false, deliveryAttempted, ...params.telemetry, }); } } return null; }; if ( params.deliveryRequested && !params.skipHeartbeatDelivery && !params.skipMessagingToolDelivery ) { if (!params.resolvedDelivery.ok) { if (!params.deliveryBestEffort) { return { result: failDeliveryTarget(params.resolvedDelivery.error.message), delivered, deliveryAttempted, summary, outputText, synthesizedText, deliveryPayloads, }; } logWarn(`[cron:${params.job.id}] ${params.resolvedDelivery.error.message}`); return { result: params.withRunSession({ status: "ok", summary, outputText, deliveryAttempted, ...params.telemetry, }), delivered, deliveryAttempted, summary, outputText, synthesizedText, deliveryPayloads, }; } // Route text-only cron announce output back through the main session so it // follows the same system-message injection path as subagent completions. // Keep direct outbound delivery only for structured payloads (media/channel // data), which cannot be represented by the shared announce flow. // // Forum/topic targets should also use direct delivery. Announce flow can // be swallowed by ANNOUNCE_SKIP/NO_REPLY in the target agent turn, which // silently drops cron output for topic-bound sessions. const useDirectDelivery = params.deliveryPayloadHasStructuredContent || params.resolvedDelivery.threadId != null; if (useDirectDelivery) { const directResult = await deliverViaDirect(params.resolvedDelivery); if (directResult) { return { result: directResult, delivered, deliveryAttempted, summary, outputText, synthesizedText, deliveryPayloads, }; } } else { const announceResult = await deliverViaAnnounce(params.resolvedDelivery); if (announceResult) { return { result: announceResult, delivered, deliveryAttempted, summary, outputText, synthesizedText, deliveryPayloads, }; } } } return { delivered, deliveryAttempted, summary, outputText, synthesizedText, deliveryPayloads, }; }