basant307/AI_Governance_Project
048
1/**2 * cron_create tool — creates a new cron job (in-session or durable).3 */4 5import type { ToolInvocation, ToolResult } from './tools.js';6import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';7import { ToolNames, ToolDisplayNames } from './tool-names.js';8import type { Config } from '../config/config.js';9import type { PermissionDecision } from '../permissions/types.js';10import { parseCron, nextFireTime } from '../utils/cronParser.js';11import { humanReadableCron } from '../utils/cronDisplay.js';12import { CRON_TASKS_DISPLAY_PATH } from '../services/cronTasksFile.js';13 14/** "1 day" / "7 days" / "0.5 days". Callers handle the Infinity case. */15function formatDays(days: number): string {16 return days === 1 ? '1 day' : `${days} days`;17}18 19/**20 * Expiry paragraph for the tool description. The max age comes from config21 * (settings / QWEN_CODE_CRON_MAX_AGE_DAYS) at construction time —22 * changing it requires a restart, so baking it into the static23 * description is safe. Infinity (setting 0) disables expiry.24 */25function recurringExpiryBlurb(maxAgeDays: number): string {26 if (!Number.isFinite(maxAgeDays)) {27 return (28 'Recurring tasks never auto-expire in this configuration — they keep ' +29 'firing until deleted with CronDelete. Tell the user the job runs ' +30 'until cancelled when scheduling recurring jobs.'31 );32 }33 const span = formatDays(maxAgeDays);34 return (35 `Recurring tasks auto-expire after ${span} — they fire one final time, ` +36 'then are deleted. This bounds how long a forgotten schedule keeps ' +37 `firing. Tell the user about the ${span} limit when scheduling ` +38 'recurring jobs.'39 );40}41 42export interface CronCreateParams {43 cron: string;44 prompt: string;45 recurring?: boolean;46 durable?: boolean;47}48 49class CronCreateInvocation extends BaseToolInvocation<50 CronCreateParams,51 ToolResult52> {53 constructor(54 private config: Config,55 params: CronCreateParams,56 ) {57 super(params);58 }59 60 getDescription(): string {61 return `${this.params.cron}: ${this.params.prompt}`;62 }63 64 /**65 * The scheduled prompt fires against the agent at cron-trigger time66 * and executes with full tool access. The CronCreateTool's L3 default67 * must NOT be 'allow', because AUTO mode short-circuits at L4 when68 * `finalPermission === 'allow'` — the classifier never runs and an69 * arbitrary scheduled prompt is silently approved. `'ask'` routes70 * the call through the classifier (or manual approval in DEFAULT).71 */72 override async getDefaultPermission(): Promise<PermissionDecision> {73 return 'ask';74 }75 76 async execute(): Promise<ToolResult> {77 const scheduler = this.config.getCronScheduler();78 const recurring = this.params.recurring !== false;79 const durable = this.params.durable === true;80 const prompt = this.params.prompt.trim();81 82 try {83 // Validate cron expression before creating the job84 parseCron(this.params.cron);85 // Reject expressions that parse but never match a real date86 // (e.g. "0 0 30 2 *") — otherwise the job would be accepted and87 // silently never fire. Throws with a clear message.88 nextFireTime(this.params.cron, new Date());89 90 const job = durable91 ? await scheduler.createDurable(this.params.cron, prompt, recurring)92 : scheduler.create(this.params.cron, prompt, recurring);93 94 const display = humanReadableCron(job.cronExpr);95 const returnDisplay = `Scheduled ${job.id} (${display})${durable ? ' [durable]' : ''}`;96 97 const where = durable98 ? `Persisted to ${CRON_TASKS_DISPLAY_PATH}`99 : 'Session-only (not written to disk, dies when Qwen Code exits)';100 const maxAgeDays = this.config.getCronRecurringMaxAgeDays();101 const expiry = Number.isFinite(maxAgeDays)102 ? `Auto-expires after ${formatDays(maxAgeDays)}. Use CronDelete to cancel sooner.`103 : 'Never auto-expires. Use CronDelete to cancel.';104 const llmContent = recurring105 ? `Scheduled recurring job ${job.id} (${job.cronExpr}). ${where}. ` +106 expiry107 : `Scheduled one-shot task ${job.id} (${job.cronExpr}). ${where}. ` +108 'It will fire once then auto-delete.';109 110 return { llmContent, returnDisplay };111 } catch (error) {112 const message = error instanceof Error ? error.message : String(error);113 return {114 llmContent: `Error creating cron job: ${message}`,115 returnDisplay: message,116 error: { message },117 };118 }119 }120}121 122export class CronCreateTool extends BaseDeclarativeTool<123 CronCreateParams,124 ToolResult125> {126 static readonly Name = ToolNames.CRON_CREATE;127 128 constructor(private config: Config) {129 // Resolved once: each call re-reads/re-parses the env var and an130 // invalid value would warn on every call site below.131 const maxAgeDays = config.getCronRecurringMaxAgeDays();132 super(133 CronCreateTool.Name,134 ToolDisplayNames.CRON_CREATE,135 'Schedule a prompt to be enqueued at a future time. Use for both recurring schedules and one-shot reminders.\n\n' +136 '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.\n\n' +137 '## One-shot tasks (recurring: false)\n\n' +138 'For "remind me at X" or "at <time>, do Y" requests — fire once then auto-delete.\n' +139 'Pin minute/hour/day-of-month/month to specific values:\n' +140 ' "remind me at 2:30pm today to check the deploy" → cron: "30 14 <today_dom> <today_month> *", recurring: false\n' +141 ' "tomorrow morning, run the smoke test" → cron: "57 8 <tomorrow_dom> <tomorrow_month> *", recurring: false\n\n' +142 '## Recurring jobs (recurring: true, the default)\n\n' +143 'For "every N minutes" / "every hour" / "weekdays at 9am" requests:\n' +144 ' "*/5 * * * *" (every 5 min), "0 * * * *" (hourly), "0 9 * * 1-5" (weekdays at 9am local)\n\n' +145 '## Avoid the :00 and :30 minute marks when the task allows it\n\n' +146 '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 on the API at the same instant. When the user\'s request is approximate, pick a minute that is NOT 0 or 30:\n' +147 ' "every morning around 9" → "57 8 * * *" or "3 9 * * *" (not "0 9 * * *")\n' +148 ' "hourly" → "7 * * * *" (not "0 * * * *")\n' +149 ' "in an hour or so, remind me to..." → pick whatever minute you land on, don\'t round\n\n' +150 '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.\n\n' +151 '## Durability\n\n' +152 'By default (durable: false) the job lives only in this Qwen Code session — nothing is written to disk, and the job is gone when Qwen Code exits. ' +153 `Pass durable: true to write to ${CRON_TASKS_DISPLAY_PATH} so the job survives restarts. ` +154 'Only use durable: true when the user explicitly asks for persistence ("keep doing this every day", "set this up permanently"). ' +155 'Most "remind me in 5 minutes" requests should stay session-only.\n\n' +156 '## Runtime behavior\n\n' +157 'Jobs only fire while the REPL is idle (not mid-query). The scheduler adds a small deterministic jitter on top of whatever you pick: recurring tasks fire up to 10% of their period late (max 15 min); one-shot tasks landing on :00 or :30 fire up to 90 s early. Picking an off-minute is still the bigger lever.\n\n' +158 `${recurringExpiryBlurb(maxAgeDays)}\n\n` +159 'Returns a job ID you can pass to CronDelete.',160 Kind.Other,161 {162 type: 'object',163 properties: {164 cron: {165 type: 'string',166 description:167 'Standard 5-field cron expression in local time: "M H DoM Mon DoW" (e.g. "*/5 * * * *" = every 5 minutes, "30 14 28 2 *" = Feb 28 at 2:30pm local once).',168 },169 prompt: {170 type: 'string',171 description: 'The prompt to enqueue at each fire time.',172 },173 recurring: {174 type: 'boolean',175 description:176 `true (default) = fire on every cron match until deleted${177 Number.isFinite(maxAgeDays)178 ? ` or auto-expired after ${formatDays(maxAgeDays)}`179 : ''180 }. ` +181 'false = fire once at the next match, then auto-delete. Use false for "remind me at X" one-shot requests with pinned minute/hour/dom/month.',182 },183 durable: {184 type: 'boolean',185 description: `true = persist to ${CRON_TASKS_DISPLAY_PATH} and survive restarts. false (default) = in-memory only, dies when Qwen Code exits. Use true only when the user asks the task to survive across sessions.`,186 },187 },188 required: ['cron', 'prompt'],189 additionalProperties: false,190 },191 true, // isOutputMarkdown192 false, // canUpdateOutput193 true, // shouldDefer — scheduling is infrequent194 false, // alwaysLoad195 'cron schedule reminder recurring timer',196 );197 }198 199 protected createInvocation(200 params: CronCreateParams,201 ): ToolInvocation<CronCreateParams, ToolResult> {202 return new CronCreateInvocation(this.config, params);203 }204 205 protected override validateToolParamValues(206 params: CronCreateParams,207 ): string | null {208 if (!params.prompt || params.prompt.trim() === '') {209 return 'Parameter "prompt" must be a non-empty string.';210 }211 return null;212 }213 214 /**215 * Forward the prompt and cadence to the classifier. The scheduled216 * prompt will be enqueued and executed against the agent at fire-time,217 * so it must go through the same scrutiny as a direct command. Without218 * this override the default projection returns `''` and the classifier219 * sees `cron_create({})` — blind to what the agent will be asked to220 * do in 8 hours.221 */222 override toAutoClassifierInput(223 params: CronCreateParams,224 ): Record<string, unknown> {225 return {226 cron: params.cron,227 prompt: params.prompt,228 recurring: params.recurring ?? true,229 durable: params.durable ?? false,230 };231 }232}233 