@sema-agent/core
Version:
Stateless, task-oriented AI agent core
482 lines (463 loc) • 29.7 kB
JavaScript
import { Type } from "typebox";
import { defineTool, errorResult } from "../core/tools.js";
import { hasScheduler, isValidCronExpr, } from "../core/scheduler.js";
import { toolFace } from "../core/tool-catalog-entries.js";
export const SCHEDULE_WAKEUP_TOOL_NAME = "ScheduleWakeup";
export const AUTONOMOUS_LOOP_SENTINEL = "<<autonomous-loop>>";
export const AUTONOMOUS_LOOP_DYNAMIC_SENTINEL = "<<autonomous-loop-dynamic>>";
const WAKEUP_LABEL = "loop-wakeup";
const SCHEDULE_WAKEUP_PROMPT = `Schedule when to resume work in /loop dynamic mode — the user invoked /loop without an interval, asking you to self-pace iterations of a specific task.
A wakeup only fires while THIS session is alive — it does not survive the session's end (a single-shot headless run ends when your final answer lands, taking pending wakeups with it). Do not park deliverable-producing work behind a wakeup near the end of a task; finish it, or start it in a self-detaching form.
Do NOT schedule a short-interval wakeup to poll for background work you started — when harness-tracked work finishes, you are re-invoked automatically, so polling is wasted. Instead schedule a long fallback (1200s+) so the loop survives if the work hangs or never notifies. The exception is external work the harness cannot track (a CI run, a deploy, a remote queue) — there, pick a delay matched to how fast that state actually changes.
Pass the same /loop prompt back via \`prompt\` each turn so the next firing repeats the task. For an autonomous /loop (no user prompt), pass the literal sentinel \`${AUTONOMOUS_LOOP_DYNAMIC_SENTINEL}\` as \`prompt\` instead — the runtime resolves it back to the autonomous-loop instructions at fire time. (There is a similar \`${AUTONOMOUS_LOOP_SENTINEL}\` sentinel for CronCreate-based autonomous loops; do not confuse the two — ${SCHEDULE_WAKEUP_TOOL_NAME} always uses the \`-dynamic\` variant.) To end the loop, call this tool with \`stop: true\` (omit every other field) — the loop ends immediately and no further wakeups fire.
Set \`noop: true\` if nothing changed — you checked and there's nothing to report ("no change", "still waiting", "quiet hold"). Set \`noop: false\` if something happened worth keeping — you edited a file, posted a message, advanced state, or surfaced a finding. Consecutive \`noop: true\` ticks are collapsed in the user's terminal view and tracked as a streak, so long quiet holds stay legible to the user without scrolling. Omit \`noop\` when stopping (\`stop: true\`).
## Picking delaySeconds
The provider prompt cache decides how expensive a wake-up is: waking inside the cache TTL re-reads your conversation context cached (fast, cheap); waking past it re-reads everything uncached. The TTL depends on the provider route this session uses — Anthropic-family routes default to about 5 minutes (1-hour optional), while some routes (e.g. DeepSeek) typically retain unused prefixes for hours, with no guaranteed TTL.
In either regime: never schedule extra wakeups just to keep the cache warm — they cost more than the cache miss they avoid. Match the delay to what you're actually waiting for: when actively polling external state the harness can't notify you about (a CI run, a deploy, a remote queue), pick the delay from how fast that state actually changes; for idle ticks with no specific signal to watch, default to **1200s–1800s** (20–30 min) — the user can always interrupt if they need you sooner.
If this session's route uses a short (~5-minute) cache TTL, two refinements: under 300s (60s–270s) the cache stays warm, so prefer 270s over 300s when actively polling (300s is the worst-of-both — you pay the miss without amortizing it); and commit to 1200s+ rather than repeated ~300s waits, so one cache miss buys a long wait. On routes with hours-long cache retention, skip these refinements — any delay in the clamp range re-reads your context cached, so pick purely from the signal you're waiting for.
The runtime clamps to [60, 3600], so you don't need to clamp yourself.
## The reason field
One short sentence on what you chose and why. Goes to telemetry and is shown back to the user. "watching CI run" beats "waiting." The user reads this to understand what you're doing without having to predict your cadence in advance — make it specific.
`;
function durableScopeOf(c) {
return c.principal ?? "default";
}
function toSchedulerContext(c, scope) {
return {
scope,
...(c.principal !== undefined ? { principal: c.principal } : {}),
...(c.sessionId !== undefined ? { sessionId: c.sessionId } : {}),
...(c.taskConfig !== undefined ? { taskConfig: c.taskConfig } : {}),
};
}
const CRON_FIELD_RANGES = [
{ min: 0, max: 59 },
{ min: 0, max: 23 },
{ min: 1, max: 31 },
{ min: 1, max: 12 },
{ min: 0, max: 6 },
];
const CRON_FIELD_NAMES = ["minute", "hour", "day-of-month", "month", "day-of-week"];
const MAX_DAYS_IN_MONTH = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
function expandCronField(field, { min, max }) {
const out = new Set();
const isDow = min === 0 && max === 6;
for (const part of field.split(",")) {
const step = part.match(/^\*(?:\/(\d+))?$/);
if (step) {
const n = step[1] ? parseInt(step[1], 10) : 1;
if (n < 1)
return null;
for (let i = min; i <= max; i += n)
out.add(i);
continue;
}
const range = part.match(/^(\d+)-(\d+)(?:\/(\d+))?$/);
if (range) {
const lo = parseInt(range[1] ?? "", 10);
const hi = parseInt(range[2] ?? "", 10);
const n = range[3] ? parseInt(range[3], 10) : 1;
const effMax = isDow ? 7 : max;
if (lo > hi || n < 1 || lo < min || hi > effMax)
return null;
for (let i = lo; i <= hi; i += n)
out.add(isDow && i === 7 ? 0 : i);
continue;
}
if (/^\d+$/.test(part)) {
let n = parseInt(part, 10);
if (isDow && n === 7)
n = 0;
if (n < min || n > max)
return null;
out.add(n);
continue;
}
return null;
}
return out.size > 0 ? [...out].sort((a, b) => a - b) : null;
}
export function cronScheduleError(expr) {
const std = expr.trim().split(/\s+/);
if (std.length === 6) {
return (`its leading seconds field is not supported — the resident scheduler fires on 5-field cron, so one ` +
`minute is the finest granularity available. Pass "${std.slice(1).join(" ")}" if that cadence works.`);
}
if (std.length !== 5) {
return `expected 5 fields (minute hour day-of-month month day-of-week), got ${std.length}.`;
}
const expanded = [];
for (let i = 0; i < 5; i++) {
const r = CRON_FIELD_RANGES[i];
const vals = expandCronField(std[i] ?? "", r);
if (!vals)
return `invalid ${CRON_FIELD_NAMES[i]} field "${std[i]}" — allowed ${r.min}-${r.max} (syntax: N, N-M, */N, comma lists).`;
expanded.push(vals);
}
const dom = expanded[2];
const month = expanded[3];
const dow = expanded[4];
const domWild = dom.length === 31;
const dowWild = dow.length === 7;
if (!domWild && dowWild) {
const fires = month.some((mo) => dom.some((d) => d <= (MAX_DAYS_IN_MONTH[mo - 1] ?? 31)));
if (!fires) {
return (`it never fires — day-of-month ${dom.join(",")} does not exist in month(s) ${month.join(",")} ` +
`(e.g. February has at most 29 days). Pick a real calendar date.`);
}
}
return null;
}
const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
function cronExprFromSummary(when) {
const trimmed = when.trim();
return trimmed.startsWith("cron ") ? trimmed.slice("cron ".length).trim() : trimmed;
}
export function cronToHuman(expr) {
const std = cronExprFromSummary(expr).split(/\s+/);
if (std.length !== 5)
return expr;
const [minute = "", hour = "", dom = "", month = "", dow = ""] = std;
if (dom !== "*" || month !== "*")
return expr;
const everyMin = minute.match(/^\*\/(\d+)$/);
if (everyMin && hour === "*" && dow === "*") {
const n = parseInt(everyMin[1] ?? "", 10);
return n === 1 ? "every minute" : `every ${n} minutes`;
}
if (!/^\d+$/.test(minute))
return expr;
const m = parseInt(minute, 10);
const mm = `:${m.toString().padStart(2, "0")}`;
if (hour === "*" && dow === "*")
return m === 0 ? "every hour" : `every hour at ${mm}`;
const everyHour = hour.match(/^\*\/(\d+)$/);
if (everyHour && dow === "*") {
const n = parseInt(everyHour[1] ?? "", 10);
const suffix = m === 0 ? "" : ` at ${mm}`;
return n === 1 ? `every hour${suffix}` : `every ${n} hours${suffix}`;
}
if (!/^\d+$/.test(hour))
return expr;
const time = `${parseInt(hour, 10)}${mm}`;
if (dow === "*")
return `daily at ${time}`;
if (/^\d$/.test(dow))
return `weekly on ${DAY_NAMES[parseInt(dow, 10) % 7]} at ${time}`;
if (dow === "1-5")
return `weekdays at ${time}`;
return expr;
}
export function createSchedulerTools(env, ctx) {
if (!hasScheduler(env))
return [];
const sched = env;
const sessionCtx = toSchedulerContext(ctx, ctx.scope);
const durableCtx = toSchedulerContext(ctx, durableScopeOf(ctx));
const lanesDiffer = sessionCtx.scope !== durableCtx.scope;
let cronCreateChain = Promise.resolve();
const serializedCronCreateOp = (fn) => {
const next = cronCreateChain.then(fn, fn);
cronCreateChain = next.then(() => undefined, () => undefined);
return next;
};
const cronCreateDescription = `Schedule a prompt to be enqueued at a future time. Use for both recurring schedules and one-shot reminders. The task runs UNATTENDED when it fires, so write a fully self-contained \`prompt\` (it will not see this conversation). Use CronList to see what you've scheduled, CronDelete to remove one.
## One-shot tasks (schedule kind "delay" or "at")
For "remind me at X" / "in N minutes, do Y" — fire once:
- "check the deploy in 30 minutes" → schedule: { "kind": "delay", "delaySec": 1800 }
- "tomorrow at 8:57am, run the smoke test" → schedule: { "kind": "at", "atMs": <epoch ms of that local time> }
## Recurring jobs (schedule kind "cron")
Uses standard 5-field cron in the user's local timezone: minute hour day-of-month month day-of-week. "0 9 * * *" means 9am local — no timezone conversion needed:
- "every 5 minutes" → schedule: { "kind": "cron", "expr": "*/5 * * * *" }
- "hourly" → { "kind": "cron", "expr": "7 * * * *" }
- "weekdays around 9am" → { "kind": "cron", "expr": "57 8 * * 1-5" }
## Call forms and lifetime (\`durable\`)
Two equivalent ways to say when — pass exactly ONE:
- the \`schedule\` object above (kinds "cron" / "at" / "delay"); defaults to \`durable: true\`
- the shorthand \`cron\` string (a bare 5-field cron expression); defaults to \`durable: false\`
\`durable: true\` hands the job to the resident scheduler: it survives this session and keeps firing until deleted. \`durable: false\` binds the job to this session: it is removed when the session ends — use it for "in a little while, within this session" work so it never outlives the conversation. Pass \`durable\` explicitly to override either default. \`recurring: false\` (cron schedules only) removes the job after its first firing; the default is to fire on every cron match.
## Avoid the :00 and :30 minute marks when the task allows it
Every user who asks for "9am" gets \`0 9\`, and every user who asks for "hourly" gets \`0 *\` — which means requests from across the planet land at the same instant. When the user's request is approximate, pick a minute that is NOT 0 or 30:
- "every morning around 9" → "57 8 * * *" or "3 9 * * *" (not "0 9 * * *")
- "hourly" → "7 * * * *" (not "0 * * * *")
- "in an hour or so, remind me to..." → pick whatever delaySec you land on, don't round
Only use minute 0 or 30 when the user names that exact time and clearly means it ("at 9:00 sharp", "at half past", coordinating with a meeting). When in doubt, nudge a few minutes early or late — the user will not notice, and the fleet will.`;
const cronCreate = defineTool({
name: "CronCreate",
...toolFace("cron-create"),
description: cronCreateDescription,
parameters: Type.Object({
prompt: Type.String({ description: "Self-contained instructions for the future task (it runs unattended)." }),
schedule: Type.Optional(Type.Union([
Type.Object({
kind: Type.Literal("cron"),
expr: Type.String({ description: "5-field cron expression (minute hour day-of-month month day-of-week)." }),
}),
Type.Object({ kind: Type.Literal("at"), atMs: Type.Number({ description: "Absolute epoch ms to fire once." }) }),
Type.Object({ kind: Type.Literal("delay"), delaySec: Type.Number({ description: "Seconds from now to fire once." }) }),
], { description: 'When to fire. Pass exactly one of `schedule` or `cron`. Defaults to `durable: true`.' })),
cron: Type.Optional(Type.String({
description: 'Shorthand call form: a bare 5-field cron expression (equivalent to schedule: { "kind": "cron", "expr": ... }). Pass exactly one of `schedule` or `cron`. Defaults to `durable: false` (session-scoped).',
})),
recurring: Type.Optional(Type.Boolean({
description: "Cron schedules only. false = remove the job after its first firing; default true = fire on every cron match.",
})),
durable: Type.Optional(Type.Boolean({
description: "true = persists in the resident scheduler beyond this session (default for the `schedule` object form); false = session-scoped, removed when the session ends (default for the `cron` string form).",
})),
label: Type.Optional(Type.String({ description: "Short label (also a dedup key with the schedule)." })),
}),
execute: async (args) => serializedCronCreateOp(async () => {
const a = args;
if (a.schedule !== undefined && a.cron !== undefined) {
return errorResult("Error (CronCreate): pass exactly one of `schedule` (object form) or `cron` (string form), not both.");
}
if (a.schedule === undefined && a.cron === undefined) {
return errorResult("Error (CronCreate): a schedule is required — pass `schedule` (kinds cron/at/delay) or the shorthand `cron` string.");
}
if (a.prompt === undefined) {
return errorResult("Error (CronCreate): `prompt` is required — the task runs unattended, so it needs self-contained instructions.");
}
const when = a.schedule ?? { kind: "cron", expr: a.cron };
if (when.kind === "cron") {
if (!isValidCronExpr(when.expr)) {
return errorResult(`Error (CronCreate): invalid cron expression "${when.expr}" — only 5 space-separated cron fields (digits and * / , -) are allowed (a 6th leading seconds field is parsed but always rejected next — this scheduler has no seconds hand).`);
}
const deepErr = cronScheduleError(when.expr);
if (deepErr)
return errorResult(`Error (CronCreate): cron expression "${when.expr}" — ${deepErr}`);
}
else if (a.recurring !== undefined) {
return errorResult(`Error (CronCreate): \`recurring\` only applies to cron schedules — "${when.kind}" fires once by nature; omit \`recurring\`.`);
}
const durable = a.durable ?? a.schedule !== undefined;
if (!durable) {
if (sched.schedulerCapabilities.supportsSessionLifetime !== true) {
return errorResult("Error (CronCreate): this scheduler backend does not support session-scoped jobs (they could not be cleaned up when the session ends). Pass `durable: true` to schedule a persistent job instead.");
}
if (ctx.sessionId === undefined) {
return errorResult("Error (CronCreate): session-scoped jobs need a session to bind to, and this run has none. Pass `durable: true` to schedule a persistent job instead.");
}
}
const intent = {
prompt: a.prompt,
when,
...(a.label !== undefined ? { label: a.label } : {}),
...(!durable ? { lifetime: "session" } : {}),
...(a.recurring !== undefined ? { recurring: a.recurring } : {}),
};
const opCtx = durable ? durableCtx : sessionCtx;
const before = await sched.list(opCtx);
const knownIds = before.ok ? new Set(before.value.map((s) => s.id)) : undefined;
const r = await sched.schedule(intent, opCtx);
if (!r.ok)
return errorResult(`Error (CronCreate): ${r.error.message}`);
const replaced = knownIds?.has(r.value.id);
const humanSchedule = when.kind === "cron"
? cronToHuman(when.expr)
: when.kind === "delay"
? `once in ${when.delaySec}s`
: `once at ${new Date(when.atMs).toISOString()}`;
const named = `${r.value.id}${a.label ? ` (${a.label})` : ""}`;
const content = replaced === true
? `Updated scheduled task ${named} — it replaced an existing job with the same schedule and label, whose prompt is now gone.`
: replaced === false
? `Scheduled task ${named}.`
: `Scheduled (or updated) task ${named} — the scheduler's listing was unavailable, so whether this replaced an existing job with the same schedule and label is unknown.`;
return {
content,
details: {
type: "cron-create",
id: r.value.id,
humanSchedule,
recurring: when.kind === "cron" && a.recurring !== false,
durable,
...(replaced !== undefined ? { replaced } : {}),
},
};
}),
});
const cancelLanesFor = async (id) => {
if (!lanesDiffer)
return [sessionCtx];
for (const lane of [sessionCtx, durableCtx]) {
const listed = await sched.list(lane);
if (listed.ok && listed.value.some((s) => s.id === id))
return [lane];
}
return [sessionCtx, durableCtx];
};
const cronCancel = defineTool({
name: "CronDelete",
...toolFace("cron-delete"),
description: "Cancel a cron job previously scheduled with CronCreate. Idempotent; ids also appear in CronList.",
parameters: Type.Object({ id: Type.String({ description: "Job ID returned by CronCreate." }) }),
execute: async (args) => {
const { id } = args;
const taskId = id;
const [firstLane, ...otherLanes] = await cancelLanesFor(taskId);
let r = await sched.cancel(taskId, firstLane);
for (const lane of otherLanes) {
if (r.ok || r.error.code !== "not_found")
break;
r = await sched.cancel(taskId, lane);
}
if (!r.ok) {
if (r.error.code === "not_found")
return `CronDelete: no scheduled task "${id}" (already gone or not yours).`;
return errorResult(`Error (CronDelete): ${r.error.message}`);
}
return { content: `Cancelled scheduled task ${id}.`, details: { type: "cron-delete", id } };
},
});
const cronList = defineTool({
name: "CronList",
...toolFace("cron-list"),
description: "List all cron jobs scheduled via CronCreate (id, when, label).",
parameters: Type.Object({}),
execute: async () => {
const rows = [];
const seen = new Set();
for (const lane of lanesDiffer ? [sessionCtx, durableCtx] : [sessionCtx]) {
const listed = await sched.list(lane);
if (!listed.ok)
return errorResult(`Error (CronList): ${listed.error.message}`);
for (const s of listed.value) {
if (seen.has(s.id))
continue;
seen.add(s.id);
rows.push(s);
}
}
if (rows.length === 0) {
return { content: "No scheduled tasks.", details: { type: "cron-list", jobs: [] } };
}
const content = rows
.map((s) => {
const shown = cronExprFromSummary(s.when);
const human = cronToHuman(s.when);
const tier = s.lifetime === "session" ? " [session]" : "";
const once = s.recurring === false ? " [once]" : "";
return `- ${s.id}: ${shown}${human !== s.when ? ` — ${human}` : ""}${s.label ? ` (${s.label})` : ""}${tier}${once}`;
})
.join("\n");
const jobs = rows.map((s) => {
const human = cronToHuman(s.when);
return {
id: s.id,
cron: cronExprFromSummary(s.when),
humanSchedule: human !== s.when ? human : s.when,
...(s.label !== undefined ? { label: s.label } : {}),
...(s.recurring !== undefined ? { recurring: s.recurring } : {}),
...(s.lifetime !== undefined ? { durable: s.lifetime === "durable" } : {}),
};
});
return { content, details: { type: "cron-list", jobs } };
},
});
const listPendingWakeups = async () => {
const r = await sched.list(sessionCtx);
if (!r.ok)
return { err: r.error.message };
return {
ids: r.value
.filter((s) => s.mode === "session-wakeup" || (s.mode === undefined && s.label === WAKEUP_LABEL))
.map((s) => s.id),
};
};
const cancelWakeups = async (ids) => {
let n = 0;
for (const id of ids) {
const r = await sched.cancel(id, sessionCtx);
if (r.ok)
n += 1;
}
return n;
};
let wakeupChain = Promise.resolve();
const serializedWakeupOp = (fn) => {
const next = wakeupChain.then(fn, fn);
wakeupChain = next.then(() => undefined, () => undefined);
return next;
};
const wakeupMountNote = sched.schedulerCapabilities.supportsSessionWakeup === false
? `
MOUNT NOTE: this host has no resident wakeup leg, so ${SCHEDULE_WAKEUP_TOOL_NAME} refuses every scheduling call here — do not plan a self-paced loop around it (\`stop: true\` still works).`
: sched.schedulerCapabilities.supportsSessionLifetime === true
? ""
: `
MOUNT NOTE: this host does not vouch for session-scoped scheduling, so a wakeup scheduled here is PERSISTENT — contrary to the paragraph above, it can outlive this session instead of ending with it. End the loop explicitly with \`stop: true\` when the work is done; do not rely on the session ending to cancel it.`;
const scheduleWakeup = defineTool({
name: SCHEDULE_WAKEUP_TOOL_NAME,
...toolFace("schedule-wakeup"),
description: `${SCHEDULE_WAKEUP_PROMPT}${wakeupMountNote}`,
parameters: Type.Object({
delaySeconds: Type.Optional(Type.Number({
description: "Seconds from now to wake up. Clamped to [60, 3600] by the runtime. Required unless `stop` is true.",
})),
reason: Type.Optional(Type.String({
description: "One short sentence explaining the chosen delay. Goes to telemetry and is shown to the user. Be specific. Required unless `stop` is true.",
})),
prompt: Type.Optional(Type.String({
description: `The /loop input to fire on wake-up. Pass the same /loop input verbatim each turn so the next firing re-enters the skill and continues the loop. For autonomous /loop (no user prompt), pass the literal sentinel \`${AUTONOMOUS_LOOP_DYNAMIC_SENTINEL}\` instead (the dynamic-pacing variant, not the CronCreate-mode \`${AUTONOMOUS_LOOP_SENTINEL}\`). Required unless \`stop\` is true.`,
})),
stop: Type.Optional(Type.Boolean({
description: "Immediately end the dynamic loop: cancel this session's pending wakeup(s) and schedule nothing. All other fields are ignored when true.",
})),
noop: Type.Optional(Type.Boolean({
description: "true = nothing changed (you checked and there is nothing to report). false = something happened worth keeping (edited a file, posted a message, advanced state, surfaced a finding). Consecutive noop:true ticks are collapsed in the user's terminal view and tracked as a streak. Required unless `stop` is true.",
})),
}),
execute: async (args) => serializedWakeupOp(async () => {
const a = args;
if (a.stop === true) {
const pending = await listPendingWakeups();
if ("err" in pending)
return errorResult(`Error (${SCHEDULE_WAKEUP_TOOL_NAME}): ${pending.err}`);
const cancelledWakeups = await cancelWakeups(pending.ids);
return {
content: `Loop stopped. Cancelled ${cancelledWakeups} pending wakeup(s); recurring cron tasks are unaffected.`,
details: { type: "schedule-wakeup", stopped: true, cancelledWakeups, scheduledFor: 0, clampedDelaySeconds: 0, wasClamped: false },
};
}
if (a.delaySeconds === undefined || a.reason === undefined || a.prompt === undefined) {
return errorResult(`Error (${SCHEDULE_WAKEUP_TOOL_NAME}): delaySeconds, reason and prompt are required unless \`stop\` is true.`);
}
if (a.noop === undefined) {
return errorResult(`Error (${SCHEDULE_WAKEUP_TOOL_NAME}): \`noop\` is required unless \`stop\` is true — pass \`noop: true\` if this tick changed nothing, \`noop: false\` if something happened worth keeping.`);
}
if (sched.schedulerCapabilities.supportsSessionWakeup === false) {
return errorResult(`Error (${SCHEDULE_WAKEUP_TOOL_NAME}): this environment has no resident scheduler that can honor a session wakeup — the wakeup would never fire. Wait in the foreground instead, or start the work in a self-detaching form.`);
}
const sessionReapVouched = sched.schedulerCapabilities.supportsSessionLifetime === true;
const clampedDelaySeconds = Math.min(3600, Math.max(60, Math.round(a.delaySeconds)));
const wasClamped = clampedDelaySeconds !== a.delaySeconds;
const pending = await listPendingWakeups();
const cancelledCount = "err" in pending ? 0 : await cancelWakeups(pending.ids);
const cleanupIncomplete = "err" in pending || cancelledCount < pending.ids.length;
const r = await sched.schedule({
prompt: a.prompt,
when: { kind: "delay", delaySec: clampedDelaySeconds },
label: WAKEUP_LABEL,
mode: "session-wakeup",
...(sessionReapVouched ? { lifetime: "session" } : {}),
}, sessionCtx);
if (!r.ok)
return errorResult(`Error (${SCHEDULE_WAKEUP_TOOL_NAME}): ${r.error.message}`);
const scheduledFor = Date.now() + clampedDelaySeconds * 1000;
const hhmmss = new Date(scheduledFor).toTimeString().slice(0, 8);
const clampNote = wasClamped ? ` (clamped to ${clampedDelaySeconds}s from your requested value)` : "";
const cleanupNote = cleanupIncomplete
? " Warning: the previously pending wakeup(s) for this session could not all be listed or cancelled, so MORE THAN ONE wakeup may now be armed (you would be re-invoked twice). Call this tool with `stop: true` and then re-schedule to get back to a single one."
: "";
const reapNote = sessionReapVouched
? ""
: " Note: this scheduler does not vouch for session-lifetime reap, so this wakeup is scheduled as a persistent one and may outlive the session — end the loop explicitly with `stop: true` rather than relying on the session ending.";
return {
content: `Next wakeup scheduled for ${hhmmss} (in ${clampedDelaySeconds}s)${clampNote}. Nothing more to do this turn — the harness re-invokes you when the wakeup fires or a task-notification arrives.${reapNote}${cleanupNote}`,
details: { type: "schedule-wakeup", stopped: false, scheduledFor, clampedDelaySeconds, wasClamped, reason: a.reason, noop: a.noop },
};
}),
});
return ctx.sessionId !== undefined ? [cronCreate, cronCancel, cronList, scheduleWakeup] : [cronCreate, cronCancel, cronList];
}