feat(cron): introduce origin context and delivery mode for cron jobs (#6)

- Added `originContext` to `createCronTool` for routing replies back to the originating session/channel.
- Updated `CronJob` and related types to include `origin` and `deliveryMode` properties.
- Enhanced `resolveDeliveryTarget` to utilize origin context for determining delivery targets.
- Modified various components to support the new origin context and delivery mode functionality, ensuring replies are routed correctly based on the job's origin.
This commit is contained in:
Chris Liu 2026-01-28 21:11:24 +08:00 committed by GitHub
parent d0408c3dbf
commit 174fa8c9cb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 241 additions and 18 deletions

View File

@ -88,6 +88,12 @@ export function createMoltbotTools(options?: {
}), }),
createCronTool({ createCronTool({
agentSessionKey: options?.agentSessionKey, agentSessionKey: options?.agentSessionKey,
originContext: {
channel: options?.agentChannel,
to: options?.agentTo,
accountId: options?.agentAccountId,
threadId: options?.agentThreadId,
},
}), }),
createMessageTool({ createMessageTool({
agentAccountId: options?.agentAccountId, agentAccountId: options?.agentAccountId,

View File

@ -42,6 +42,13 @@ const CronToolSchema = Type.Object({
type CronToolOptions = { type CronToolOptions = {
agentSessionKey?: string; agentSessionKey?: string;
/** Origin context for routing cron job replies back to where the job was created */
originContext?: {
channel?: string;
to?: string;
accountId?: string;
threadId?: string | number;
};
}; };
type ChatMessage = { type ChatMessage = {
@ -210,6 +217,18 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con
(job as { agentId?: string }).agentId = agentId; (job as { agentId?: string }).agentId = agentId;
} }
} }
// Inject origin context for reply routing (unless already specified)
if (job && typeof job === "object" && !("origin" in job) && opts?.originContext) {
const origin: Record<string, unknown> = {};
if (opts.originContext.channel) origin.channel = opts.originContext.channel;
if (opts.originContext.to) origin.to = opts.originContext.to;
if (opts.originContext.accountId) origin.accountId = opts.originContext.accountId;
if (opts.originContext.threadId != null) origin.threadId = opts.originContext.threadId;
if (opts.agentSessionKey) origin.sessionKey = opts.agentSessionKey;
if (Object.keys(origin).length > 0) {
(job as { origin?: Record<string, unknown> }).origin = origin;
}
}
const contextMessages = const contextMessages =
typeof params.contextMessages === "number" && Number.isFinite(params.contextMessages) typeof params.contextMessages === "number" && Number.isFinite(params.contextMessages)
? params.contextMessages ? params.contextMessages

View File

@ -12,6 +12,7 @@ import {
resolveOutboundTarget, resolveOutboundTarget,
resolveSessionDeliveryTarget, resolveSessionDeliveryTarget,
} from "../../infra/outbound/targets.js"; } from "../../infra/outbound/targets.js";
import type { CronDeliveryMode, CronOrigin } from "../types.js";
export async function resolveDeliveryTarget( export async function resolveDeliveryTarget(
cfg: MoltbotConfig, cfg: MoltbotConfig,
@ -20,15 +21,67 @@ export async function resolveDeliveryTarget(
channel?: "last" | ChannelId; channel?: "last" | ChannelId;
to?: string; to?: string;
}, },
options?: {
/** Origin context from when the job was created */
origin?: CronOrigin;
/** Delivery mode: "origin" (default) or "current" */
deliveryMode?: CronDeliveryMode;
},
): Promise<{ ): Promise<{
channel: Exclude<OutboundChannel, "none">; channel: Exclude<OutboundChannel, "none">;
to?: string; to?: string;
accountId?: string; accountId?: string;
threadId?: string | number;
mode: "explicit" | "implicit"; mode: "explicit" | "implicit";
error?: Error; error?: Error;
}> { }> {
const requestedChannel = typeof jobPayload.channel === "string" ? jobPayload.channel : "last"; const origin = options?.origin;
const explicitTo = typeof jobPayload.to === "string" ? jobPayload.to : undefined; const deliveryMode = options?.deliveryMode ?? "origin";
// Explicit payload values always take precedence
const hasExplicitChannel =
typeof jobPayload.channel === "string" && jobPayload.channel !== "last";
const hasExplicitTo = typeof jobPayload.to === "string" && jobPayload.to.trim() !== "";
// Determine if we should use origin context
// Use origin when: deliveryMode is "origin" (default), origin exists, and no explicit overrides
const useOrigin =
deliveryMode === "origin" &&
origin &&
!hasExplicitChannel &&
!hasExplicitTo &&
(origin.channel || origin.to);
if (useOrigin && origin.channel && origin.to) {
// Route directly to origin - no need to look up main session
const docked = resolveOutboundTarget({
channel: origin.channel,
to: origin.to,
cfg,
accountId: origin.accountId,
mode: "explicit",
});
return {
channel: origin.channel as Exclude<OutboundChannel, "none">,
to: docked.ok ? docked.to : undefined,
accountId: origin.accountId,
threadId: origin.threadId,
mode: "explicit",
error: docked.ok ? undefined : docked.error,
};
}
// Fall back to main session lookup (current behavior)
const requestedChannel = hasExplicitChannel
? (jobPayload.channel as ChannelId)
: useOrigin && origin?.channel
? origin.channel
: "last";
const explicitTo = hasExplicitTo
? jobPayload.to
: useOrigin && origin?.to
? origin.to
: undefined;
const sessionCfg = cfg.session; const sessionCfg = cfg.session;
const mainSessionKey = resolveAgentMainSessionKey({ cfg, agentId }); const mainSessionKey = resolveAgentMainSessionKey({ cfg, agentId });
@ -45,11 +98,16 @@ export async function resolveDeliveryTarget(
let fallbackChannel: Exclude<OutboundChannel, "none"> | undefined; let fallbackChannel: Exclude<OutboundChannel, "none"> | undefined;
if (!preliminary.channel) { if (!preliminary.channel) {
try { // Try origin channel as fallback before config/default
const selection = await resolveMessageChannelSelection({ cfg }); if (useOrigin && origin?.channel) {
fallbackChannel = selection.channel; fallbackChannel = origin.channel as Exclude<OutboundChannel, "none">;
} catch { } else {
fallbackChannel = preliminary.lastChannel ?? DEFAULT_CHAT_CHANNEL; try {
const selection = await resolveMessageChannelSelection({ cfg });
fallbackChannel = selection.channel;
} catch {
fallbackChannel = preliminary.lastChannel ?? DEFAULT_CHAT_CHANNEL;
}
} }
} }
@ -67,22 +125,25 @@ export async function resolveDeliveryTarget(
const channel = resolved.channel ?? fallbackChannel ?? DEFAULT_CHAT_CHANNEL; const channel = resolved.channel ?? fallbackChannel ?? DEFAULT_CHAT_CHANNEL;
const mode = resolved.mode as "explicit" | "implicit"; const mode = resolved.mode as "explicit" | "implicit";
const toCandidate = resolved.to; const toCandidate = resolved.to;
const accountId = useOrigin && origin?.accountId ? origin.accountId : resolved.accountId;
const threadId = useOrigin && origin?.threadId != null ? origin.threadId : resolved.threadId;
if (!toCandidate) { if (!toCandidate) {
return { channel, to: undefined, accountId: resolved.accountId, mode }; return { channel, to: undefined, accountId, threadId, mode };
} }
const docked = resolveOutboundTarget({ const docked = resolveOutboundTarget({
channel, channel,
to: toCandidate, to: toCandidate,
cfg, cfg,
accountId: resolved.accountId, accountId,
mode, mode,
}); });
return { return {
channel, channel,
to: docked.ok ? docked.to : undefined, to: docked.ok ? docked.to : undefined,
accountId: resolved.accountId, accountId,
threadId,
mode, mode,
error: docked.ok ? undefined : docked.error, error: docked.ok ? undefined : docked.error,
}; };

View File

@ -234,10 +234,18 @@ export async function runCronIsolatedAgentTurn(params: {
deliveryMode === "explicit" || (deliveryMode === "auto" && hasExplicitTarget); deliveryMode === "explicit" || (deliveryMode === "auto" && hasExplicitTarget);
const bestEffortDeliver = agentPayload?.bestEffortDeliver === true; const bestEffortDeliver = agentPayload?.bestEffortDeliver === true;
const resolvedDelivery = await resolveDeliveryTarget(cfgWithAgentDefaults, agentId, { const resolvedDelivery = await resolveDeliveryTarget(
channel: agentPayload?.channel ?? "last", cfgWithAgentDefaults,
to: agentPayload?.to, agentId,
}); {
channel: agentPayload?.channel ?? "last",
to: agentPayload?.to,
},
{
origin: params.job.origin,
deliveryMode: params.job.deliveryMode,
},
);
const userTimezone = resolveUserTimezone(params.cfg.agents?.defaults?.userTimezone); const userTimezone = resolveUserTimezone(params.cfg.agents?.defaults?.userTimezone);
const userTimeFormat = resolveUserTimeFormat(params.cfg.agents?.defaults?.timeFormat); const userTimeFormat = resolveUserTimeFormat(params.cfg.agents?.defaults?.timeFormat);

View File

@ -95,6 +95,8 @@ export function createJob(state: CronServiceState, input: CronJobCreate): CronJo
state: { state: {
...input.state, ...input.state,
}, },
origin: input.origin,
deliveryMode: input.deliveryMode,
}; };
assertSupportedJobSpec(job); assertSupportedJobSpec(job);
job.state.nextRunAtMs = computeJobNextRunAtMs(job, now); job.state.nextRunAtMs = computeJobNextRunAtMs(job, now);
@ -115,6 +117,8 @@ export function applyJobPatch(job: CronJob, patch: CronJobPatch) {
if ("agentId" in patch) { if ("agentId" in patch) {
job.agentId = normalizeOptionalAgentId((patch as { agentId?: unknown }).agentId); job.agentId = normalizeOptionalAgentId((patch as { agentId?: unknown }).agentId);
} }
if (patch.origin) job.origin = patch.origin;
if (patch.deliveryMode) job.deliveryMode = patch.deliveryMode;
assertSupportedJobSpec(job); assertSupportedJobSpec(job);
} }

View File

@ -1,5 +1,12 @@
import type { HeartbeatRunResult } from "../../infra/heartbeat-wake.js"; import type { HeartbeatRunResult } from "../../infra/heartbeat-wake.js";
import type { CronJob, CronJobCreate, CronJobPatch, CronStoreFile } from "../types.js"; import type {
CronDeliveryMode,
CronJob,
CronJobCreate,
CronJobPatch,
CronOrigin,
CronStoreFile,
} from "../types.js";
export type CronEvent = { export type CronEvent = {
jobId: string; jobId: string;
@ -24,7 +31,16 @@ export type CronServiceDeps = {
log: Logger; log: Logger;
storePath: string; storePath: string;
cronEnabled: boolean; cronEnabled: boolean;
enqueueSystemEvent: (text: string, opts?: { agentId?: string }) => void; enqueueSystemEvent: (
text: string,
opts?: {
agentId?: string;
/** Origin context for routing replies back to where the job was created */
origin?: CronOrigin;
/** Delivery mode: "origin" (default) or "current" */
deliveryMode?: CronDeliveryMode;
},
) => void;
requestHeartbeatNow: (opts?: { reason?: string }) => void; requestHeartbeatNow: (opts?: { reason?: string }) => void;
runHeartbeatOnce?: (opts?: { reason?: string }) => Promise<HeartbeatRunResult>; runHeartbeatOnce?: (opts?: { reason?: string }) => Promise<HeartbeatRunResult>;
runIsolatedAgentJob: (params: { job: CronJob; message: string }) => Promise<{ runIsolatedAgentJob: (params: { job: CronJob; message: string }) => Promise<{

View File

@ -149,7 +149,11 @@ export async function executeJob(
); );
return; return;
} }
state.deps.enqueueSystemEvent(text, { agentId: job.agentId }); state.deps.enqueueSystemEvent(text, {
agentId: job.agentId,
origin: job.origin,
deliveryMode: job.deliveryMode,
});
if (job.wakeMode === "now" && state.deps.runHeartbeatOnce) { if (job.wakeMode === "now" && state.deps.runHeartbeatOnce) {
const reason = `cron:${job.id}`; const reason = `cron:${job.id}`;
const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)); const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));

View File

@ -53,6 +53,26 @@ export type CronIsolation = {
postToMainMaxChars?: number; postToMainMaxChars?: number;
}; };
/**
* Origin context: where the cron job was created.
* Used for routing replies back to the originating session/channel.
*/
export type CronOrigin = {
/** The channel where the job was created (telegram, whatsapp, etc.) */
channel?: ChannelId;
/** The chat/conversation ID where the job was created */
to?: string;
/** The account ID (for multi-account setups) */
accountId?: string;
/** The thread ID (for threaded conversations) */
threadId?: string | number;
/** The session key where the job was created */
sessionKey?: string;
};
/** Delivery routing mode for cron job replies */
export type CronDeliveryMode = "origin" | "current";
export type CronJobState = { export type CronJobState = {
nextRunAtMs?: number; nextRunAtMs?: number;
runningAtMs?: number; runningAtMs?: number;
@ -77,6 +97,17 @@ export type CronJob = {
payload: CronPayload; payload: CronPayload;
isolation?: CronIsolation; isolation?: CronIsolation;
state: CronJobState; state: CronJobState;
/**
* Origin context: where the job was created.
* Replies are routed back here by default.
*/
origin?: CronOrigin;
/**
* Delivery routing mode:
* - "origin" (default): Route replies to where the job was created
* - "current": Route replies to the session's current lastChannel/lastTo
*/
deliveryMode?: CronDeliveryMode;
}; };
export type CronStoreFile = { export type CronStoreFile = {

View File

@ -85,6 +85,27 @@ export const CronIsolationSchema = Type.Object(
{ additionalProperties: false }, { additionalProperties: false },
); );
/**
* Origin context: where the cron job was created.
* Used for routing replies back to the originating session/channel.
*/
export const CronOriginSchema = Type.Object(
{
channel: Type.Optional(NonEmptyString),
to: Type.Optional(Type.String()),
accountId: Type.Optional(Type.String()),
threadId: Type.Optional(Type.Union([Type.String(), Type.Integer()])),
sessionKey: Type.Optional(Type.String()),
},
{ additionalProperties: false },
);
/** Delivery routing mode for cron job replies */
export const CronDeliveryModeSchema = Type.Union([
Type.Literal("origin"),
Type.Literal("current"),
]);
export const CronJobStateSchema = Type.Object( export const CronJobStateSchema = Type.Object(
{ {
nextRunAtMs: Type.Optional(Type.Integer({ minimum: 0 })), nextRunAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
@ -115,6 +136,8 @@ export const CronJobSchema = Type.Object(
payload: CronPayloadSchema, payload: CronPayloadSchema,
isolation: Type.Optional(CronIsolationSchema), isolation: Type.Optional(CronIsolationSchema),
state: CronJobStateSchema, state: CronJobStateSchema,
origin: Type.Optional(CronOriginSchema),
deliveryMode: Type.Optional(CronDeliveryModeSchema),
}, },
{ additionalProperties: false }, { additionalProperties: false },
); );
@ -140,6 +163,8 @@ export const CronAddParamsSchema = Type.Object(
wakeMode: Type.Union([Type.Literal("next-heartbeat"), Type.Literal("now")]), wakeMode: Type.Union([Type.Literal("next-heartbeat"), Type.Literal("now")]),
payload: CronPayloadSchema, payload: CronPayloadSchema,
isolation: Type.Optional(CronIsolationSchema), isolation: Type.Optional(CronIsolationSchema),
origin: Type.Optional(CronOriginSchema),
deliveryMode: Type.Optional(CronDeliveryModeSchema),
}, },
{ additionalProperties: false }, { additionalProperties: false },
); );
@ -157,6 +182,8 @@ export const CronJobPatchSchema = Type.Object(
payload: Type.Optional(CronPayloadPatchSchema), payload: Type.Optional(CronPayloadPatchSchema),
isolation: Type.Optional(CronIsolationSchema), isolation: Type.Optional(CronIsolationSchema),
state: Type.Optional(Type.Partial(CronJobStateSchema)), state: Type.Optional(Type.Partial(CronJobStateSchema)),
origin: Type.Optional(CronOriginSchema),
deliveryMode: Type.Optional(CronDeliveryModeSchema),
}, },
{ additionalProperties: false }, { additionalProperties: false },
); );

View File

@ -1,17 +1,24 @@
import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import type { CliDeps } from "../cli/deps.js"; import type { CliDeps } from "../cli/deps.js";
import { loadConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js";
import { resolveAgentMainSessionKey } from "../config/sessions.js"; import {
loadSessionStore,
resolveAgentMainSessionKey,
resolveStorePath,
updateSessionStore,
} from "../config/sessions.js";
import { runCronIsolatedAgentTurn } from "../cron/isolated-agent.js"; import { runCronIsolatedAgentTurn } from "../cron/isolated-agent.js";
import { appendCronRunLog, resolveCronRunLogPath } from "../cron/run-log.js"; import { appendCronRunLog, resolveCronRunLogPath } from "../cron/run-log.js";
import { CronService } from "../cron/service.js"; import { CronService } from "../cron/service.js";
import { resolveCronStorePath } from "../cron/store.js"; import { resolveCronStorePath } from "../cron/store.js";
import type { CronDeliveryMode, CronOrigin } from "../cron/types.js";
import { runHeartbeatOnce } from "../infra/heartbeat-runner.js"; import { runHeartbeatOnce } from "../infra/heartbeat-runner.js";
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js"; import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
import { enqueueSystemEvent } from "../infra/system-events.js"; import { enqueueSystemEvent } from "../infra/system-events.js";
import { getChildLogger } from "../logging.js"; import { getChildLogger } from "../logging.js";
import { normalizeAgentId } from "../routing/session-key.js"; import { normalizeAgentId } from "../routing/session-key.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import { normalizeSessionDeliveryFields } from "../utils/delivery-context.js";
export type GatewayCronState = { export type GatewayCronState = {
cron: CronService; cron: CronService;
@ -52,6 +59,46 @@ export function buildGatewayCronService(params: {
cfg: runtimeConfig, cfg: runtimeConfig,
agentId, agentId,
}); });
// If origin is provided and deliveryMode is "origin" (default), update the
// session's delivery context so the heartbeat routes replies to the origin.
const origin = opts?.origin;
const deliveryMode = opts?.deliveryMode ?? "origin";
if (origin && deliveryMode === "origin" && (origin.channel || origin.to)) {
const storePath = resolveStorePath(runtimeConfig.session?.store, { agentId });
const store = loadSessionStore(storePath);
const entry = store[sessionKey];
if (entry) {
const deliveryFields = normalizeSessionDeliveryFields({
deliveryContext: {
channel: origin.channel,
to: origin.to,
accountId: origin.accountId,
threadId: origin.threadId,
},
});
// Update session entry with origin delivery context
const updatedEntry = {
...entry,
updatedAt: Date.now(),
deliveryContext: deliveryFields.deliveryContext,
lastChannel: deliveryFields.lastChannel ?? entry.lastChannel,
lastTo: deliveryFields.lastTo ?? entry.lastTo,
lastAccountId: deliveryFields.lastAccountId ?? entry.lastAccountId,
lastThreadId: deliveryFields.lastThreadId ?? entry.lastThreadId,
};
store[sessionKey] = updatedEntry;
void updateSessionStore(storePath, (s) => {
s[sessionKey] = updatedEntry;
}).catch((err) => {
cronLogger.warn(
{ err: String(err), sessionKey },
"cron: failed to update session delivery context for origin routing",
);
});
}
}
enqueueSystemEvent(text, { sessionKey }); enqueueSystemEvent(text, { sessionKey });
}, },
requestHeartbeatNow, requestHeartbeatNow,