diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md index 1cfaba9e3..882cf816a 100644 --- a/docs/automation/cron-jobs.md +++ b/docs/automation/cron-jobs.md @@ -127,6 +127,11 @@ Isolated jobs can deliver output to a channel. The job payload can specify: If `channel` or `to` is omitted, cron can fall back to the main session’s “last route” (the last place the agent replied). +Delivery notes: +- If `to` is set, cron auto-delivers the agent’s final output even if `deliver` is omitted. +- Use `deliver: true` when you want last-route delivery without an explicit `to`. +- Use `deliver: false` to keep output internal even if a `to` is present. + Target format reminders: - Slack/Discord targets should use explicit prefixes (e.g. `channel:`, `user:`) to avoid ambiguity. - Telegram topics should use the `:topic:` form (see below). diff --git a/src/cli/cron-cli/register.cron-add.ts b/src/cli/cron-cli/register.cron-add.ts index 4e34da9c3..83a48650b 100644 --- a/src/cli/cron-cli/register.cron-add.ts +++ b/src/cli/cron-cli/register.cron-add.ts @@ -80,7 +80,11 @@ export function registerCronAddCommand(cron: Command) { .option("--thinking ", "Thinking level for agent jobs (off|minimal|low|medium|high)") .option("--model ", "Model override for agent jobs (provider/model or alias)") .option("--timeout-seconds ", "Timeout seconds for agent jobs") - .option("--deliver", "Deliver agent output", false) + .option( + "--deliver", + "Deliver agent output (required when using last-route delivery without --to)", + false, + ) .option("--channel ", `Delivery channel (${getCronChannelOptions()})`, "last") .option( "--to ", @@ -159,10 +163,10 @@ export function registerCronAddCommand(cron: Command) { : undefined, timeoutSeconds: timeoutSeconds && Number.isFinite(timeoutSeconds) ? timeoutSeconds : undefined, - deliver: Boolean(opts.deliver), + deliver: opts.deliver ? true : undefined, channel: typeof opts.channel === "string" ? opts.channel : "last", to: typeof opts.to === "string" && opts.to.trim() ? opts.to.trim() : undefined, - bestEffortDeliver: Boolean(opts.bestEffortDeliver), + bestEffortDeliver: opts.bestEffortDeliver ? true : undefined, }; })(); diff --git a/src/cli/cron-cli/register.cron-edit.ts b/src/cli/cron-cli/register.cron-edit.ts index 9f7df0213..d24a4282a 100644 --- a/src/cli/cron-cli/register.cron-edit.ts +++ b/src/cli/cron-cli/register.cron-edit.ts @@ -35,7 +35,11 @@ export function registerCronEditCommand(cron: Command) { .option("--thinking ", "Thinking level for agent jobs") .option("--model ", "Model override for agent jobs") .option("--timeout-seconds ", "Timeout seconds for agent jobs") - .option("--deliver", "Deliver agent output", false) + .option( + "--deliver", + "Deliver agent output (required when using last-route delivery without --to)", + false, + ) .option("--channel ", `Delivery channel (${getCronChannelOptions()})`) .option( "--to ", @@ -125,10 +129,10 @@ export function registerCronEditCommand(cron: Command) { thinking, timeoutSeconds: timeoutSeconds && Number.isFinite(timeoutSeconds) ? timeoutSeconds : undefined, - deliver: Boolean(opts.deliver), + deliver: opts.deliver ? true : undefined, channel: typeof opts.channel === "string" ? opts.channel : undefined, to: typeof opts.to === "string" ? opts.to : undefined, - bestEffortDeliver: Boolean(opts.bestEffortDeliver), + bestEffortDeliver: opts.bestEffortDeliver ? true : undefined, }; } diff --git a/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts b/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts index 6f5ffec77..f2217b92c 100644 --- a/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts +++ b/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts @@ -203,6 +203,116 @@ describe("runCronIsolatedAgentTurn", () => { }); }); + it("auto-delivers when explicit target is set without deliver flag", async () => { + await withTempHome(async (home) => { + const storePath = await writeSessionStore(home); + const deps: CliDeps = { + sendMessageWhatsApp: vi.fn(), + sendMessageTelegram: vi.fn().mockResolvedValue({ + messageId: "t1", + chatId: "123", + }), + sendMessageDiscord: vi.fn(), + sendMessageSignal: vi.fn(), + sendMessageIMessage: vi.fn(), + }; + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads: [{ text: "hello from cron" }], + meta: { + durationMs: 5, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); + + const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN; + process.env.TELEGRAM_BOT_TOKEN = ""; + try { + const res = await runCronIsolatedAgentTurn({ + cfg: makeCfg(home, storePath, { + channels: { telegram: { botToken: "t-1" } }, + }), + deps, + job: makeJob({ + kind: "agentTurn", + message: "do it", + channel: "telegram", + to: "123", + }), + message: "do it", + sessionKey: "cron:job-1", + lane: "cron", + }); + + expect(res.status).toBe("ok"); + expect(deps.sendMessageTelegram).toHaveBeenCalledWith( + "123", + "hello from cron", + expect.objectContaining({ verbose: false }), + ); + } finally { + if (prevTelegramToken === undefined) { + delete process.env.TELEGRAM_BOT_TOKEN; + } else { + process.env.TELEGRAM_BOT_TOKEN = prevTelegramToken; + } + } + }); + }); + + it("skips auto-delivery when messaging tool already sent to the target", async () => { + await withTempHome(async (home) => { + const storePath = await writeSessionStore(home); + const deps: CliDeps = { + sendMessageWhatsApp: vi.fn(), + sendMessageTelegram: vi.fn().mockResolvedValue({ + messageId: "t1", + chatId: "123", + }), + sendMessageDiscord: vi.fn(), + sendMessageSignal: vi.fn(), + sendMessageIMessage: vi.fn(), + }; + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads: [{ text: "sent" }], + meta: { + durationMs: 5, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + didSendViaMessagingTool: true, + messagingToolSentTargets: [{ tool: "message", provider: "telegram", to: "123" }], + }); + + const prevTelegramToken = process.env.TELEGRAM_BOT_TOKEN; + process.env.TELEGRAM_BOT_TOKEN = ""; + try { + const res = await runCronIsolatedAgentTurn({ + cfg: makeCfg(home, storePath, { + channels: { telegram: { botToken: "t-1" } }, + }), + deps, + job: makeJob({ + kind: "agentTurn", + message: "do it", + channel: "telegram", + to: "123", + }), + message: "do it", + sessionKey: "cron:job-1", + lane: "cron", + }); + + expect(res.status).toBe("ok"); + expect(deps.sendMessageTelegram).not.toHaveBeenCalled(); + } finally { + if (prevTelegramToken === undefined) { + delete process.env.TELEGRAM_BOT_TOKEN; + } else { + process.env.TELEGRAM_BOT_TOKEN = prevTelegramToken; + } + } + }); + }); + it("delivers telegram topic targets via channel send", async () => { await withTempHome(async (home) => { const storePath = await writeSessionStore(home); diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 44e2ac885..296cf6aad 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -19,6 +19,7 @@ import { resolveThinkingDefault, } from "../../agents/model-selection.js"; import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js"; +import type { MessagingToolSend } from "../../agents/pi-embedded-messaging.js"; import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js"; import { getSkillsSnapshotVersion } from "../../agents/skills/refresh.js"; import { resolveAgentTimeoutMs } from "../../agents/timeout.js"; @@ -49,6 +50,20 @@ import { } from "./helpers.js"; import { resolveCronSession } from "./session.js"; +function matchesMessagingToolDeliveryTarget( + target: MessagingToolSend, + delivery: { channel: string; to?: string; accountId?: string }, +): boolean { + if (!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; + } + return target.to === delivery.to; +} + export type RunCronAgentTurnResult = { status: "ok" | "error" | "skipped"; summary?: string; @@ -197,14 +212,17 @@ export async function runCronIsolatedAgentTurn(params: { params.job.payload.kind === "agentTurn" ? params.job.payload.timeoutSeconds : undefined, }); - const delivery = params.job.payload.kind === "agentTurn" && params.job.payload.deliver === true; - const bestEffortDeliver = - params.job.payload.kind === "agentTurn" && params.job.payload.bestEffortDeliver === true; + const agentPayload = params.job.payload.kind === "agentTurn" ? params.job.payload : null; + const deliveryMode = + agentPayload?.deliver === true ? "explicit" : agentPayload?.deliver === false ? "off" : "auto"; + const hasExplicitTarget = Boolean(agentPayload?.to && agentPayload.to.trim()); + const deliveryRequested = + deliveryMode === "explicit" || (deliveryMode === "auto" && hasExplicitTarget); + const bestEffortDeliver = agentPayload?.bestEffortDeliver === true; const resolvedDelivery = await resolveDeliveryTarget(cfgWithAgentDefaults, agentId, { - channel: - params.job.payload.kind === "agentTurn" ? (params.job.payload.channel ?? "last") : "last", - to: params.job.payload.kind === "agentTurn" ? params.job.payload.to : undefined, + channel: agentPayload?.channel ?? "last", + to: agentPayload?.to, }); const base = `[cron:${params.job.id} ${params.job.name}] ${params.message}`.trim(); @@ -342,9 +360,20 @@ export async function runCronIsolatedAgentTurn(params: { // Skip delivery for heartbeat-only responses (HEARTBEAT_OK with no real content). const ackMaxChars = resolveHeartbeatAckMaxChars(agentCfg); - const skipHeartbeatDelivery = delivery && isHeartbeatOnlyResponse(payloads, ackMaxChars); + const skipHeartbeatDelivery = deliveryRequested && isHeartbeatOnlyResponse(payloads, ackMaxChars); + const skipMessagingToolDelivery = + deliveryRequested && + deliveryMode === "auto" && + runResult.didSendViaMessagingTool === true && + (runResult.messagingToolSentTargets ?? []).some((target) => + matchesMessagingToolDeliveryTarget(target, { + channel: resolvedDelivery.channel, + to: resolvedDelivery.to, + accountId: resolvedDelivery.accountId, + }), + ); - if (delivery && !skipHeartbeatDelivery) { + if (deliveryRequested && !skipHeartbeatDelivery && !skipMessagingToolDelivery) { if (!resolvedDelivery.to) { const reason = resolvedDelivery.error?.message ?? "Cron delivery requires a recipient (--to).";