feat: mirror delivered outbound messages (#1031)
Co-authored-by: T Savo <TSavo@users.noreply.github.com>
This commit is contained in:
@@ -25,6 +25,14 @@ export function pickSummaryFromPayloads(payloads: Array<{ text?: string | undefi
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function pickLastNonEmptyTextFromPayloads(payloads: Array<{ text?: string | undefined }>) {
|
||||
for (let i = payloads.length - 1; i >= 0; i--) {
|
||||
const clean = (payloads[i]?.text ?? "").trim();
|
||||
if (clean) return clean;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all payloads are just heartbeat ack responses (HEARTBEAT_OK).
|
||||
* Returns true if delivery should be skipped because there's no real content.
|
||||
|
||||
@@ -41,6 +41,7 @@ import type { CronJob } from "../types.js";
|
||||
import { resolveDeliveryTarget } from "./delivery-target.js";
|
||||
import {
|
||||
isHeartbeatOnlyResponse,
|
||||
pickLastNonEmptyTextFromPayloads,
|
||||
pickSummaryFromOutput,
|
||||
pickSummaryFromPayloads,
|
||||
resolveHeartbeatAckMaxChars,
|
||||
@@ -50,6 +51,8 @@ import { resolveCronSession } from "./session.js";
|
||||
export type RunCronAgentTurnResult = {
|
||||
status: "ok" | "error" | "skipped";
|
||||
summary?: string;
|
||||
/** Last non-empty agent text output (not truncated). */
|
||||
outputText?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
@@ -333,6 +336,7 @@ export async function runCronIsolatedAgentTurn(params: {
|
||||
}
|
||||
const firstText = payloads[0]?.text ?? "";
|
||||
const summary = pickSummaryFromPayloads(payloads) ?? pickSummaryFromOutput(firstText);
|
||||
const outputText = pickLastNonEmptyTextFromPayloads(payloads);
|
||||
|
||||
// Skip delivery for heartbeat-only responses (HEARTBEAT_OK with no real content).
|
||||
const ackMaxChars = resolveHeartbeatAckMaxChars(agentCfg);
|
||||
@@ -346,12 +350,14 @@ export async function runCronIsolatedAgentTurn(params: {
|
||||
return {
|
||||
status: "error",
|
||||
summary,
|
||||
outputText,
|
||||
error: reason,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "skipped",
|
||||
summary: `Delivery skipped (${reason}).`,
|
||||
outputText,
|
||||
};
|
||||
}
|
||||
try {
|
||||
@@ -366,11 +372,11 @@ export async function runCronIsolatedAgentTurn(params: {
|
||||
});
|
||||
} catch (err) {
|
||||
if (!bestEffortDeliver) {
|
||||
return { status: "error", summary, error: String(err) };
|
||||
return { status: "error", summary, outputText, error: String(err) };
|
||||
}
|
||||
return { status: "ok", summary };
|
||||
return { status: "ok", summary, outputText };
|
||||
}
|
||||
}
|
||||
|
||||
return { status: "ok", summary };
|
||||
return { status: "ok", summary, outputText };
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ export type CronServiceDeps = {
|
||||
runIsolatedAgentJob: (params: { job: CronJob; message: string }) => Promise<{
|
||||
status: "ok" | "error" | "skipped";
|
||||
summary?: string;
|
||||
/** Last non-empty agent text output (not truncated). */
|
||||
outputText?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
onEvent?: (evt: CronEvent) => void;
|
||||
|
||||
@@ -66,7 +66,12 @@ export async function executeJob(
|
||||
|
||||
let deleted = false;
|
||||
|
||||
const finish = async (status: "ok" | "error" | "skipped", err?: string, summary?: string) => {
|
||||
const finish = async (
|
||||
status: "ok" | "error" | "skipped",
|
||||
err?: string,
|
||||
summary?: string,
|
||||
outputText?: string,
|
||||
) => {
|
||||
const endedAt = state.deps.nowMs();
|
||||
job.state.runningAtMs = undefined;
|
||||
job.state.lastRunAtMs = startedAt;
|
||||
@@ -108,7 +113,19 @@ export async function executeJob(
|
||||
|
||||
if (job.sessionTarget === "isolated") {
|
||||
const prefix = job.isolation?.postToMainPrefix?.trim() || "Cron";
|
||||
const body = (summary ?? err ?? status).trim();
|
||||
const mode = job.isolation?.postToMainMode ?? "summary";
|
||||
|
||||
let body = (summary ?? err ?? status).trim();
|
||||
if (mode === "full") {
|
||||
// Prefer full agent output if available; fall back to summary.
|
||||
const maxCharsRaw = job.isolation?.postToMainMaxChars;
|
||||
const maxChars = Number.isFinite(maxCharsRaw) ? Math.max(0, maxCharsRaw as number) : 8000;
|
||||
const fullText = (outputText ?? "").trim();
|
||||
if (fullText) {
|
||||
body = fullText.length > maxChars ? `${fullText.slice(0, maxChars)}…` : fullText;
|
||||
}
|
||||
}
|
||||
|
||||
const statusPrefix = status === "ok" ? prefix : `${prefix} (${status})`;
|
||||
state.deps.enqueueSystemEvent(`${statusPrefix}: ${body}`, {
|
||||
agentId: job.agentId,
|
||||
@@ -182,9 +199,10 @@ export async function executeJob(
|
||||
job,
|
||||
message: job.payload.message,
|
||||
});
|
||||
if (res.status === "ok") await finish("ok", undefined, res.summary);
|
||||
else if (res.status === "skipped") await finish("skipped", undefined, res.summary);
|
||||
else await finish("error", res.error ?? "cron job failed", res.summary);
|
||||
if (res.status === "ok") await finish("ok", undefined, res.summary, res.outputText);
|
||||
else if (res.status === "skipped")
|
||||
await finish("skipped", undefined, res.summary, res.outputText);
|
||||
else await finish("error", res.error ?? "cron job failed", res.summary, res.outputText);
|
||||
} catch (err) {
|
||||
await finish("error", String(err));
|
||||
} finally {
|
||||
|
||||
@@ -27,6 +27,14 @@ export type CronPayload =
|
||||
|
||||
export type CronIsolation = {
|
||||
postToMainPrefix?: string;
|
||||
/**
|
||||
* What to post back into the main session after an isolated run.
|
||||
* - summary: small status/summary line (default)
|
||||
* - full: the agent's final text output (optionally truncated)
|
||||
*/
|
||||
postToMainMode?: "summary" | "full";
|
||||
/** Max chars when postToMainMode="full". Default: 8000. */
|
||||
postToMainMaxChars?: number;
|
||||
};
|
||||
|
||||
export type CronJobState = {
|
||||
|
||||
Reference in New Issue
Block a user