From aa4939111c60cb5ffb6d85a5d554f9618f838915 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Sat, 28 Mar 2026 14:37:29 +0000 Subject: [PATCH 01/19] feat(cron): add in-session loop scheduling with cron tools Add session-scoped recurring jobs that fire while you work. Jobs live inside the current Qwen Code process and are gone when you exit. New tools: - cron_create: schedule a prompt to run on a cron expression - cron_list: list active cron jobs - cron_delete: cancel a scheduled job Components: - CronScheduler service for in-process job management - cronParser utility for 5-field cron expressions - /loop skill for natural language scheduling - Non-interactive mode integration to keep process alive Constraints: - Max 50 jobs per session - 3-day expiry for recurring jobs - Jitter to prevent thundering herd - No catch-up for missed fire times Co-authored-by: Qwen-Coder --- packages/cli/src/nonInteractiveCli.ts | 118 ++++++++++ packages/cli/src/ui/hooks/useGeminiStream.ts | 26 +++ .../core/src/agents/runtime/agent-core.ts | 18 +- packages/core/src/config/config.ts | 23 ++ packages/core/src/index.ts | 4 + .../core/src/services/cronScheduler.test.ts | 217 ++++++++++++++++++ packages/core/src/services/cronScheduler.ts | 211 +++++++++++++++++ .../core/src/skills/bundled/loop/SKILL.md | 35 +++ packages/core/src/tools/cron-create.test.ts | 69 ++++++ packages/core/src/tools/cron-create.ts | 116 ++++++++++ packages/core/src/tools/cron-delete.test.ts | 48 ++++ packages/core/src/tools/cron-delete.ts | 77 +++++++ packages/core/src/tools/cron-list.test.ts | 48 ++++ packages/core/src/tools/cron-list.ts | 85 +++++++ packages/core/src/tools/tool-names.ts | 6 + packages/core/src/utils/cronParser.test.ts | 148 ++++++++++++ packages/core/src/utils/cronParser.ts | 149 ++++++++++++ 17 files changed, 1395 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/services/cronScheduler.test.ts create mode 100644 packages/core/src/services/cronScheduler.ts create mode 100644 packages/core/src/skills/bundled/loop/SKILL.md create mode 100644 packages/core/src/tools/cron-create.test.ts create mode 100644 packages/core/src/tools/cron-create.ts create mode 100644 packages/core/src/tools/cron-delete.test.ts create mode 100644 packages/core/src/tools/cron-delete.ts create mode 100644 packages/core/src/tools/cron-list.test.ts create mode 100644 packages/core/src/tools/cron-list.ts create mode 100644 packages/core/src/utils/cronParser.test.ts create mode 100644 packages/core/src/utils/cronParser.ts diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 4ae0a5759..69f0f1fbf 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -371,6 +371,124 @@ export async function runNonInteractive( } currentMessages = [{ role: 'user', parts: toolResponseParts }]; } else { + // No more tool calls — check if cron jobs are keeping us alive + const scheduler = config.isCronDisabled() + ? null + : config.getCronScheduler(); + if (scheduler && scheduler.size > 0) { + // Start the scheduler and wait for all jobs to complete or be deleted. + // Each fired prompt is processed as a new turn through the same loop. + await new Promise((resolve) => { + const checkDone = () => { + if (scheduler.size === 0) { + scheduler.stop(); + resolve(); + } + }; + + scheduler.start((job: { prompt: string }) => { + // Process fired prompt as a new turn + (async () => { + try { + turnCount++; + let cronMessages: Content[] = [ + { role: 'user', parts: [{ text: job.prompt }] }, + ]; + let cronIsFirstTurn = true; + + while (true) { + const cronToolCallRequests: ToolCallRequestInfo[] = []; + const cronApiStartTime = Date.now(); + const cronStream = geminiClient.sendMessageStream( + cronMessages[0]?.parts || [], + abortController.signal, + prompt_id, + { + type: cronIsFirstTurn + ? SendMessageType.UserQuery + : SendMessageType.ToolResult, + }, + ); + cronIsFirstTurn = false; + + adapter.startAssistantMessage(); + + for await (const event of cronStream) { + if (abortController.signal.aborted) { + scheduler.stop(); + resolve(); + return; + } + adapter.processEvent(event); + if (event.type === GeminiEventType.ToolCallRequest) { + cronToolCallRequests.push(event.value); + } + } + + adapter.finalizeAssistantMessage(); + totalApiDurationMs += Date.now() - cronApiStartTime; + + if (cronToolCallRequests.length > 0) { + const cronToolResponseParts: Part[] = []; + + for (const requestInfo of cronToolCallRequests) { + const isAgentTool = requestInfo.name === 'agent'; + const { handler: outputUpdateHandler } = isAgentTool + ? createAgentToolProgressHandler( + config, + requestInfo.callId, + adapter, + ) + : createToolProgressHandler(requestInfo, adapter); + + const toolResponse = await executeToolCall( + config, + requestInfo, + abortController.signal, + { outputUpdateHandler }, + ); + + if (toolResponse.error) { + handleToolError( + requestInfo.name, + toolResponse.error, + config, + toolResponse.errorType || 'TOOL_EXECUTION_ERROR', + typeof toolResponse.resultDisplay === 'string' + ? toolResponse.resultDisplay + : undefined, + ); + } + + adapter.emitToolResult(requestInfo, toolResponse); + + if (toolResponse.responseParts) { + cronToolResponseParts.push( + ...toolResponse.responseParts, + ); + } + } + cronMessages = [ + { role: 'user', parts: cronToolResponseParts }, + ]; + } else { + // Cron turn done — check if we should exit + checkDone(); + break; + } + } + } catch (error) { + debugLogger.error('Error processing cron prompt:', error); + checkDone(); + } + })(); + }); + + // Also check immediately in case jobs were already deleted + checkDone(); + }); + } + const metrics = uiTelemetryService.getMetrics(); const usage = computeUsageFromMetrics(metrics); // Get stats for JSON format output diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 5d39654b1..8e3388b3a 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -1638,6 +1638,32 @@ export const useGeminiStream = ( storage, ]); + // ─── Cron scheduler integration ───────────────────────── + const cronQueueRef = useRef([]); + + // Start the scheduler on mount, stop on unmount + useEffect(() => { + if (config.isCronDisabled()) return; + const scheduler = config.getCronScheduler(); + scheduler.start((job: { prompt: string }) => { + cronQueueRef.current.push(job.prompt); + }); + return () => { + scheduler.stop(); + }; + }, [config]); + + // When idle, drain the cron queue one prompt at a time + useEffect(() => { + if ( + streamingState === StreamingState.Idle && + cronQueueRef.current.length > 0 + ) { + const prompt = cronQueueRef.current.shift()!; + submitQuery(prompt, SendMessageType.UserQuery); + } + }, [streamingState, submitQuery]); + return { streamingState, submitQuery, diff --git a/packages/core/src/agents/runtime/agent-core.ts b/packages/core/src/agents/runtime/agent-core.ts index 2a4b29d79..fa5e753be 100644 --- a/packages/core/src/agents/runtime/agent-core.ts +++ b/packages/core/src/agents/runtime/agent-core.ts @@ -58,6 +58,7 @@ import type { import { type AgentEventEmitter, AgentEventType } from './agent-events.js'; import { AgentStatistics, type AgentStatsSummary } from './agent-statistics.js'; import { AgentTool } from '../../tools/agent.js'; +import { ToolNames } from '../../tools/tool-names.js'; import { DEFAULT_QWEN_MODEL } from '../../config/models.js'; import { type ContextState, templateString } from './agent-headless.js'; @@ -273,6 +274,15 @@ export class AgentCore { const toolRegistry = this.runtimeContext.getToolRegistry(); const toolsList: FunctionDeclaration[] = []; + // Tools excluded from subagents: AgentTool (prevent recursion) and + // cron tools (session-scoped, should only be used by the main session). + const excludedFromSubagents = new Set([ + AgentTool.Name, + ToolNames.CRON_CREATE, + ToolNames.CRON_LIST, + ToolNames.CRON_DELETE, + ]); + if (this.toolConfig) { const asStrings = this.toolConfig.tools.filter( (t): t is string => typeof t === 'string', @@ -286,11 +296,13 @@ export class AgentCore { toolsList.push( ...toolRegistry .getFunctionDeclarations() - .filter((t) => t.name !== AgentTool.Name), + .filter((t) => !(t.name && excludedFromSubagents.has(t.name))), ); } else { toolsList.push( - ...toolRegistry.getFunctionDeclarationsFiltered(asStrings), + ...toolRegistry.getFunctionDeclarationsFiltered( + asStrings.filter((name) => !excludedFromSubagents.has(name)), + ), ); } toolsList.push(...onlyInlineDecls); @@ -299,7 +311,7 @@ export class AgentCore { toolsList.push( ...toolRegistry .getFunctionDeclarations() - .filter((t) => t.name !== AgentTool.Name), + .filter((t) => !(t.name && excludedFromSubagents.has(t.name))), ); } diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index dc743d9b9..007b22ec2 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -41,6 +41,7 @@ import { type FileEncodingType, } from '../services/fileSystemService.js'; import { GitService } from '../services/gitService.js'; +import { CronScheduler } from '../services/cronScheduler.js'; // Tools import { AskUserQuestionTool } from '../tools/askUserQuestion.js'; @@ -63,6 +64,9 @@ import { WebFetchTool } from '../tools/web-fetch.js'; import { WebSearchTool } from '../tools/web-search/index.js'; import { WriteFileTool } from '../tools/write-file.js'; import { LspTool } from '../tools/lsp.js'; +import { CronCreateTool } from '../tools/cron-create.js'; +import { CronListTool } from '../tools/cron-list.js'; +import { CronDeleteTool } from '../tools/cron-delete.js'; import type { LspClient } from '../lsp/types.js'; // Other modules @@ -525,6 +529,7 @@ export class Config { private readonly usageStatisticsEnabled: boolean; private geminiClient!: GeminiClient; private baseLlmClient!: BaseLlmClient; + private cronScheduler: CronScheduler | null = null; private readonly fileFiltering: { respectGitIgnore: boolean; respectQwenIgnore: boolean; @@ -1675,6 +1680,17 @@ export class Config { return this.geminiClient; } + getCronScheduler(): CronScheduler { + if (!this.cronScheduler) { + this.cronScheduler = new CronScheduler(); + } + return this.cronScheduler; + } + + isCronDisabled(): boolean { + return process.env['QWEN_CODE_DISABLE_CRON'] === '1'; + } + getEnableRecursiveFileSearch(): boolean { return this.fileFiltering.enableRecursiveFileSearch; } @@ -2194,6 +2210,13 @@ export class Config { await registerCoreTool(LspTool, this); } + // Register cron tools unless disabled + if (!this.isCronDisabled()) { + await registerCoreTool(CronCreateTool, this); + await registerCoreTool(CronListTool, this); + await registerCoreTool(CronDeleteTool, this); + } + if (!options?.skipDiscovery) { await registry.discoverAllTools(); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 66359a865..ed60bba1c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -97,12 +97,16 @@ export * from './tools/tool-registry.js'; export * from './tools/web-fetch.js'; export * from './tools/web-search/index.js'; export * from './tools/write-file.js'; +export * from './tools/cron-create.js'; +export * from './tools/cron-list.js'; +export * from './tools/cron-delete.js'; // ============================================================================ // Services // ============================================================================ export * from './services/chatRecordingService.js'; +export * from './services/cronScheduler.js'; export * from './services/fileDiscoveryService.js'; export * from './services/fileSystemService.js'; export * from './services/gitService.js'; diff --git a/packages/core/src/services/cronScheduler.test.ts b/packages/core/src/services/cronScheduler.test.ts new file mode 100644 index 000000000..59de3d80e --- /dev/null +++ b/packages/core/src/services/cronScheduler.test.ts @@ -0,0 +1,217 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { CronScheduler, type CronJob } from './cronScheduler.js'; + +describe('CronScheduler', () => { + let scheduler: CronScheduler; + + beforeEach(() => { + scheduler = new CronScheduler(); + }); + + afterEach(() => { + scheduler.destroy(); + }); + + describe('create', () => { + it('creates a job with valid fields', () => { + const job = scheduler.create('*/5 * * * *', 'test prompt', true); + expect(job.id).toHaveLength(8); + expect(job.cronExpr).toBe('*/5 * * * *'); + expect(job.prompt).toBe('test prompt'); + expect(job.recurring).toBe(true); + expect(job.createdAt).toBeGreaterThan(0); + expect(job.expiresAt).toBeGreaterThan(job.createdAt); + }); + + it('creates one-shot jobs with zero jitter', () => { + const job = scheduler.create('*/1 * * * *', 'once', false); + expect(job.jitterMs).toBe(0); + }); + + it('enforces max 50 jobs', () => { + for (let i = 0; i < 50; i++) { + scheduler.create('*/1 * * * *', `job-${i}`, true); + } + expect(() => scheduler.create('*/1 * * * *', 'job-51', true)).toThrow( + 'Maximum number of cron jobs (50) reached', + ); + }); + + it('generates unique IDs', () => { + const ids = new Set(); + for (let i = 0; i < 20; i++) { + const job = scheduler.create('*/1 * * * *', `job-${i}`, true); + ids.add(job.id); + } + expect(ids.size).toBe(20); + }); + }); + + describe('delete', () => { + it('removes an existing job', () => { + const job = scheduler.create('*/1 * * * *', 'test', true); + expect(scheduler.delete(job.id)).toBe(true); + expect(scheduler.list()).toHaveLength(0); + }); + + it('returns false for non-existent job', () => { + expect(scheduler.delete('nonexistent')).toBe(false); + }); + }); + + describe('list', () => { + it('returns empty array when no jobs', () => { + expect(scheduler.list()).toEqual([]); + }); + + it('returns all jobs', () => { + scheduler.create('*/1 * * * *', 'a', true); + scheduler.create('*/2 * * * *', 'b', false); + const jobs = scheduler.list(); + expect(jobs).toHaveLength(2); + expect(jobs.map((j) => j.prompt).sort()).toEqual(['a', 'b']); + }); + }); + + describe('size', () => { + it('tracks job count', () => { + expect(scheduler.size).toBe(0); + const job = scheduler.create('*/1 * * * *', 'a', true); + expect(scheduler.size).toBe(1); + scheduler.delete(job.id); + expect(scheduler.size).toBe(0); + }); + }); + + describe('tick', () => { + it('fires callback when a job matches', () => { + const fired: CronJob[] = []; + scheduler.start((job) => fired.push(job)); + + scheduler.create('30 10 * * *', 'match', true); + + // Tick at 10:30:59 — past any jitter (max 55s) + const date = new Date(2025, 0, 15, 10, 30, 59); + scheduler.tick(date); + + expect(fired).toHaveLength(1); + expect(fired[0]!.prompt).toBe('match'); + }); + + it('does not fire when no match', () => { + const fired: CronJob[] = []; + scheduler.start((job) => fired.push(job)); + + scheduler.create('30 10 * * *', 'no match', true); + + // Tick at 10:31 — should not fire + scheduler.tick(new Date(2025, 0, 15, 10, 31, 0)); + expect(fired).toHaveLength(0); + }); + + it('does not double-fire in same minute', () => { + const fired: CronJob[] = []; + scheduler.start((job) => fired.push(job)); + + scheduler.create('30 10 * * *', 'once per minute', true); + + // Both ticks in second 59 — past jitter + const date1 = new Date(2025, 0, 15, 10, 30, 59); + const date2 = new Date(2025, 0, 15, 10, 30, 59, 500); + scheduler.tick(date1); + scheduler.tick(date2); + + expect(fired).toHaveLength(1); + }); + + it('removes one-shot jobs after firing', () => { + const fired: CronJob[] = []; + scheduler.start((job) => fired.push(job)); + + // One-shot: jitter is 0, so second 1 is fine + scheduler.create('30 10 * * *', 'one-shot', false); + + scheduler.tick(new Date(2025, 0, 15, 10, 30, 1)); + expect(fired).toHaveLength(1); + expect(scheduler.list()).toHaveLength(0); + }); + + it('keeps recurring jobs after firing', () => { + const fired: CronJob[] = []; + scheduler.start((job) => fired.push(job)); + + scheduler.create('30 10 * * *', 'recurring', true); + + // Tick at second 59 — past any jitter + scheduler.tick(new Date(2025, 0, 15, 10, 30, 59)); + expect(fired).toHaveLength(1); + expect(scheduler.list()).toHaveLength(1); + }); + + it('removes expired jobs', () => { + scheduler.start(() => {}); + + const job = scheduler.create('*/1 * * * *', 'expire me', true); + // Tick far in the future (past expiry) + const farFuture = new Date(job.expiresAt + 1000); + scheduler.tick(farFuture); + + expect(scheduler.list()).toHaveLength(0); + }); + + it('fires in next minute after first fire', () => { + const fired: CronJob[] = []; + scheduler.start((job) => fired.push(job)); + + // Every minute + scheduler.create('* * * * *', 'every minute', true); + + scheduler.tick(new Date(2025, 0, 15, 10, 30, 59)); + expect(fired).toHaveLength(1); + + // Next minute + scheduler.tick(new Date(2025, 0, 15, 10, 31, 59)); + expect(fired).toHaveLength(2); + }); + }); + + describe('start/stop', () => { + it('starts and stops without error', () => { + scheduler.start(() => {}); + expect(scheduler.running).toBe(true); + scheduler.stop(); + expect(scheduler.running).toBe(false); + }); + + it('does not fire after stop', () => { + const fired: CronJob[] = []; + scheduler.start((job) => fired.push(job)); + scheduler.stop(); + + scheduler.create('30 10 * * *', 'no fire', true); + scheduler.tick(new Date(2025, 0, 15, 10, 30, 1)); + + // tick still works manually, but onFire is cleared + expect(fired).toHaveLength(0); + }); + + it('start is idempotent', () => { + scheduler.start(() => {}); + scheduler.start(() => {}); // should not throw or create duplicate timers + expect(scheduler.running).toBe(true); + }); + }); + + describe('destroy', () => { + it('stops and clears all jobs', () => { + scheduler.create('*/1 * * * *', 'a', true); + scheduler.create('*/2 * * * *', 'b', true); + scheduler.start(() => {}); + + scheduler.destroy(); + + expect(scheduler.running).toBe(false); + expect(scheduler.list()).toHaveLength(0); + }); + }); +}); diff --git a/packages/core/src/services/cronScheduler.ts b/packages/core/src/services/cronScheduler.ts new file mode 100644 index 000000000..b3c1434a3 --- /dev/null +++ b/packages/core/src/services/cronScheduler.ts @@ -0,0 +1,211 @@ +/** + * In-session cron scheduler. Jobs live in memory and are gone when the + * process exits. Ticks every second, fires callbacks when jobs are due. + */ + +import { matches, nextFireTime } from '../utils/cronParser.js'; + +const MAX_JOBS = 50; +const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000; +// Jitter must stay within the matching minute (< 60s). +// Cap at 55s to avoid edge cases near the minute boundary. +const MAX_JITTER_MS = 55 * 1000; + +export interface CronJob { + id: string; + cronExpr: string; + prompt: string; + recurring: boolean; + createdAt: number; + expiresAt: number; + lastFiredAt?: number; + jitterMs: number; +} + +/** + * Derives a deterministic jitter offset from a job ID and its cron period. + * Recurring jobs get up to 10% of period (capped at 15 min). + * One-shot jobs get 0 jitter. + */ +function computeJitter( + id: string, + cronExpr: string, + recurring: boolean, +): number { + if (!recurring) return 0; + + // Estimate period by computing two consecutive fire times + const now = new Date(); + try { + const first = nextFireTime(cronExpr, now); + const second = nextFireTime(cronExpr, first); + const periodMs = second.getTime() - first.getTime(); + const tenPercent = periodMs * 0.1; + const maxJitter = Math.min(tenPercent, MAX_JITTER_MS); + + // Deterministic hash from ID + let hash = 0; + for (let i = 0; i < id.length; i++) { + hash = (hash * 31 + id.charCodeAt(i)) | 0; + } + return Math.abs(hash) % Math.max(1, Math.floor(maxJitter)); + } catch { + return 0; + } +} + +function generateId(): string { + const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; + let id = ''; + for (let i = 0; i < 8; i++) { + id += chars[Math.floor(Math.random() * chars.length)]; + } + return id; +} + +export class CronScheduler { + private jobs = new Map(); + private timer: ReturnType | null = null; + private onFire: ((job: CronJob) => void) | null = null; + + /** + * Creates a new cron job. Returns the created job. + * Throws if the max job limit is reached. + */ + create(cronExpr: string, prompt: string, recurring: boolean): CronJob { + if (this.jobs.size >= MAX_JOBS) { + throw new Error( + `Maximum number of cron jobs (${MAX_JOBS}) reached. Delete some jobs first.`, + ); + } + + const id = generateId(); + const now = Date.now(); + const jitterMs = computeJitter(id, cronExpr, recurring); + + const job: CronJob = { + id, + cronExpr, + prompt, + recurring, + createdAt: now, + expiresAt: recurring ? now + THREE_DAYS_MS : now + THREE_DAYS_MS, + jitterMs, + }; + + this.jobs.set(id, job); + return job; + } + + /** + * Deletes a job by ID. Returns true if the job existed. + */ + delete(id: string): boolean { + return this.jobs.delete(id); + } + + /** + * Returns all active jobs. + */ + list(): CronJob[] { + return [...this.jobs.values()]; + } + + /** + * Returns the number of active jobs. + */ + get size(): number { + return this.jobs.size; + } + + /** + * Starts the scheduler tick. Calls `onFire` when a job is due. + * Only fires when called — does not auto-fire missed intervals. + */ + start(onFire: (job: CronJob) => void): void { + this.onFire = onFire; + if (this.timer) return; // already running + + this.timer = setInterval(() => { + this.tick(); + }, 1000); + } + + /** + * Stops the scheduler. Does not clear jobs — they remain queryable. + */ + stop(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + this.onFire = null; + } + + /** + * Returns true if the scheduler is running. + */ + get running(): boolean { + return this.timer !== null; + } + + /** + * Manual tick — checks all jobs against the current time and fires those + * that are due. Exported for testing. + */ + tick(now?: Date): void { + const currentDate = now ?? new Date(); + const currentMs = currentDate.getTime(); + + for (const job of this.jobs.values()) { + // Check expiry + if (currentMs >= job.expiresAt) { + this.jobs.delete(job.id); + continue; + } + + // Check if this minute matches + if (!matches(job.cronExpr, currentDate)) { + continue; + } + + // Apply jitter: the job fires at :00 + jitterMs of the matching minute. + // We check if we're within the jitter window. + const minuteStart = new Date(currentDate); + minuteStart.setSeconds(0, 0); + const fireTimeMs = minuteStart.getTime() + job.jitterMs; + + if (currentMs < fireTimeMs) { + continue; // Not yet time (jitter hasn't elapsed) + } + + // Prevent double-firing within the same minute + if (job.lastFiredAt) { + const lastFiredMinute = new Date(job.lastFiredAt); + lastFiredMinute.setSeconds(0, 0); + if (lastFiredMinute.getTime() === minuteStart.getTime()) { + continue; // Already fired this minute + } + } + + // Fire! + job.lastFiredAt = currentMs; + + if (!job.recurring) { + this.jobs.delete(job.id); + } + + if (this.onFire) { + this.onFire(job); + } + } + } + + /** + * Clears all jobs and stops the scheduler. + */ + destroy(): void { + this.stop(); + this.jobs.clear(); + } +} diff --git a/packages/core/src/skills/bundled/loop/SKILL.md b/packages/core/src/skills/bundled/loop/SKILL.md new file mode 100644 index 000000000..0b0969eca --- /dev/null +++ b/packages/core/src/skills/bundled/loop/SKILL.md @@ -0,0 +1,35 @@ +--- +name: loop +description: Create a recurring loop that runs a prompt on a schedule. Usage - /loop 5m check the build, /loop check the PR every 30m, /loop run tests (defaults to 10m). +allowedTools: + - cron_create +--- + +# Loop + +You are setting up a recurring in-session loop. Parse the user's input to extract: + +1. **An interval** — look for patterns like `5m`, `30m`, `2h`, `1d`, `90s`, `every 5 minutes`, `every 2 hours`, etc. + - Supported units: `s` (seconds, rounded up to 1 minute minimum), `m` (minutes), `h` (hours), `d` (days) + - If no interval is found, default to **10 minutes** + - The interval can appear at the start (`/loop 5m check the build`) or after "every" (`/loop check the build every 5m`) +2. **A prompt** — everything that isn't the interval is the prompt to run on each iteration + +## Converting intervals to cron expressions + +- **Every N minutes**: `*/N * * * *` (e.g., 5m → `*/5 * * * *`) +- **Every N hours**: `0 */N * * *` (e.g., 2h → `0 */2 * * *`) +- **Every N days**: `0 0 */N * *` (e.g., 1d → `0 0 */1 * *`) +- For seconds: round up to 1 minute minimum, use `*/1 * * * *` +- For intervals that don't divide evenly into cron (e.g., 45m), pick the closest reasonable cron expression + +## What to do + +1. Parse the interval and prompt from the user's input +2. Convert the interval to a cron expression +3. Append to the prompt: `\n\nBe concise. If nothing has changed, reply with a single short sentence.` +4. Call `cron_create` with: + - `cron_expression`: the computed cron expression + - `prompt`: the extracted prompt with the conciseness instruction appended + - `recurring`: true +5. Confirm to the user: "Loop created — I'll [description] every [interval]." diff --git a/packages/core/src/tools/cron-create.test.ts b/packages/core/src/tools/cron-create.test.ts new file mode 100644 index 000000000..a44eae733 --- /dev/null +++ b/packages/core/src/tools/cron-create.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { CronCreateTool } from './cron-create.js'; +import { CronScheduler } from '../services/cronScheduler.js'; + +function makeConfig() { + const scheduler = new CronScheduler(); + return { + getCronScheduler: () => scheduler, + _scheduler: scheduler, + } as unknown as import('../config/config.js').Config & { + _scheduler: CronScheduler; + }; +} + +describe('CronCreateTool', () => { + let config: ReturnType; + let tool: CronCreateTool; + + beforeEach(() => { + config = makeConfig(); + tool = new CronCreateTool(config); + }); + + it('has the correct name', () => { + expect(tool.name).toBe('cron_create'); + }); + + it('creates a recurring job by default', async () => { + const invocation = tool.build({ + cron_expression: '*/5 * * * *', + prompt: 'check status', + }); + const result = await invocation.execute(new AbortController().signal); + expect(result.error).toBeUndefined(); + expect(result.llmContent).toContain('recurring'); + expect(result.llmContent).toContain('ID:'); + expect(config._scheduler.list()).toHaveLength(1); + }); + + it('creates a one-shot job when recurring=false', async () => { + const invocation = tool.build({ + cron_expression: '*/1 * * * *', + prompt: 'once', + recurring: false, + }); + const result = await invocation.execute(new AbortController().signal); + expect(result.error).toBeUndefined(); + expect(result.llmContent).toContain('one-shot'); + const jobs = config._scheduler.list(); + expect(jobs).toHaveLength(1); + expect(jobs[0]!.recurring).toBe(false); + }); + + it('returns error for invalid cron expression', async () => { + const invocation = tool.build({ + cron_expression: 'bad cron', + prompt: 'fail', + }); + const result = await invocation.execute(new AbortController().signal); + expect(result.error).toBeDefined(); + }); + + it('validates required params', () => { + expect(() => + tool.build({ cron_expression: '*/1 * * * *' } as never), + ).toThrow(); + expect(() => tool.build({ prompt: 'test' } as never)).toThrow(); + }); +}); diff --git a/packages/core/src/tools/cron-create.ts b/packages/core/src/tools/cron-create.ts new file mode 100644 index 000000000..cdfaba286 --- /dev/null +++ b/packages/core/src/tools/cron-create.ts @@ -0,0 +1,116 @@ +/** + * cron_create tool — creates a new in-session cron job. + */ + +import type { ToolInvocation, ToolResult } from './tools.js'; +import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; +import { ToolNames, ToolDisplayNames } from './tool-names.js'; +import type { Config } from '../config/config.js'; +import { nextFireTime } from '../utils/cronParser.js'; + +export interface CronCreateParams { + cron_expression: string; + prompt: string; + recurring?: boolean; +} + +class CronCreateInvocation extends BaseToolInvocation< + CronCreateParams, + ToolResult +> { + constructor( + private config: Config, + params: CronCreateParams, + ) { + super(params); + } + + getDescription(): string { + const recurrence = + this.params.recurring !== false ? 'recurring' : 'one-shot'; + return `Create ${recurrence} cron job: ${this.params.cron_expression}`; + } + + async execute(): Promise { + const scheduler = this.config.getCronScheduler(); + const recurring = this.params.recurring !== false; + + try { + const job = scheduler.create( + this.params.cron_expression, + this.params.prompt, + recurring, + ); + + const next = nextFireTime(this.params.cron_expression, new Date()); + const result = [ + `Created ${recurring ? 'recurring' : 'one-shot'} cron job.`, + ` ID: ${job.id}`, + ` Expression: ${job.cronExpr}`, + ` Prompt: ${job.prompt}`, + ` Next fire: ${next.toISOString()}`, + ].join('\n'); + + return { + llmContent: result, + returnDisplay: result, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + llmContent: `Error creating cron job: ${message}`, + returnDisplay: message, + error: { message }, + }; + } + } +} + +export class CronCreateTool extends BaseDeclarativeTool< + CronCreateParams, + ToolResult +> { + static readonly Name = ToolNames.CRON_CREATE; + + constructor(private config: Config) { + super( + CronCreateTool.Name, + ToolDisplayNames.CRON_CREATE, + 'Create a new in-session cron job that fires a prompt on a schedule. ' + + 'The job runs within the current session and is gone when the session ends. ' + + 'Use standard 5-field cron expressions (minute hour day-of-month month day-of-week). ' + + 'Examples: "*/5 * * * *" (every 5 min), "0 */2 * * *" (every 2 hours), "*/1 * * * *" (every minute).', + Kind.Other, + { + type: 'object', + properties: { + cron_expression: { + type: 'string', + description: + 'Standard 5-field cron expression. Fields: minute (0-59), hour (0-23), ' + + 'day-of-month (1-31), month (1-12), day-of-week (0-6, 0=Sunday). ' + + 'Supports: *, values, ranges (1-5), steps (*/15), lists (1,15,30).', + }, + prompt: { + type: 'string', + description: + 'The prompt to send when the job fires. This is injected into the ' + + 'session as if the user typed it.', + }, + recurring: { + type: 'boolean', + description: + 'If true (default), the job fires repeatedly. If false, it fires once and is deleted.', + }, + }, + required: ['cron_expression', 'prompt'], + }, + ); + } + + protected createInvocation( + params: CronCreateParams, + ): ToolInvocation { + return new CronCreateInvocation(this.config, params); + } +} diff --git a/packages/core/src/tools/cron-delete.test.ts b/packages/core/src/tools/cron-delete.test.ts new file mode 100644 index 000000000..f0527880d --- /dev/null +++ b/packages/core/src/tools/cron-delete.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { CronDeleteTool } from './cron-delete.js'; +import { CronScheduler } from '../services/cronScheduler.js'; + +function makeConfig() { + const scheduler = new CronScheduler(); + return { + getCronScheduler: () => scheduler, + _scheduler: scheduler, + } as unknown as import('../config/config.js').Config & { + _scheduler: CronScheduler; + }; +} + +describe('CronDeleteTool', () => { + let config: ReturnType; + let tool: CronDeleteTool; + + beforeEach(() => { + config = makeConfig(); + tool = new CronDeleteTool(config); + }); + + it('has the correct name', () => { + expect(tool.name).toBe('cron_delete'); + }); + + it('deletes an existing job', async () => { + const job = config._scheduler.create('*/1 * * * *', 'test', true); + + const invocation = tool.build({ id: job.id }); + const result = await invocation.execute(new AbortController().signal); + expect(result.error).toBeUndefined(); + expect(result.llmContent).toContain('deleted'); + expect(config._scheduler.list()).toHaveLength(0); + }); + + it('returns error for non-existent job', async () => { + const invocation = tool.build({ id: 'nonexist' }); + const result = await invocation.execute(new AbortController().signal); + expect(result.error).toBeDefined(); + expect(result.llmContent).toContain('not found'); + }); + + it('validates required params', () => { + expect(() => tool.build({} as never)).toThrow(); + }); +}); diff --git a/packages/core/src/tools/cron-delete.ts b/packages/core/src/tools/cron-delete.ts new file mode 100644 index 000000000..487c69939 --- /dev/null +++ b/packages/core/src/tools/cron-delete.ts @@ -0,0 +1,77 @@ +/** + * cron_delete tool — deletes an in-session cron job by ID. + */ + +import type { ToolInvocation, ToolResult } from './tools.js'; +import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; +import { ToolNames, ToolDisplayNames } from './tool-names.js'; +import type { Config } from '../config/config.js'; + +export interface CronDeleteParams { + id: string; +} + +class CronDeleteInvocation extends BaseToolInvocation< + CronDeleteParams, + ToolResult +> { + constructor( + private config: Config, + params: CronDeleteParams, + ) { + super(params); + } + + getDescription(): string { + return `Delete cron job ${this.params.id}`; + } + + async execute(): Promise { + const scheduler = this.config.getCronScheduler(); + const deleted = scheduler.delete(this.params.id); + + if (deleted) { + const result = `Cron job ${this.params.id} deleted.`; + return { llmContent: result, returnDisplay: result }; + } else { + const result = `Cron job ${this.params.id} not found.`; + return { + llmContent: result, + returnDisplay: result, + error: { message: result }, + }; + } + } +} + +export class CronDeleteTool extends BaseDeclarativeTool< + CronDeleteParams, + ToolResult +> { + static readonly Name = ToolNames.CRON_DELETE; + + constructor(private config: Config) { + super( + CronDeleteTool.Name, + ToolDisplayNames.CRON_DELETE, + 'Delete an active in-session cron job by its ID. Use cron_list to find job IDs.', + Kind.Other, + { + type: 'object', + properties: { + id: { + type: 'string', + description: 'The 8-character ID of the cron job to delete.', + }, + }, + required: ['id'], + }, + ); + } + + protected createInvocation( + params: CronDeleteParams, + ): ToolInvocation { + return new CronDeleteInvocation(this.config, params); + } +} diff --git a/packages/core/src/tools/cron-list.test.ts b/packages/core/src/tools/cron-list.test.ts new file mode 100644 index 000000000..359fcf885 --- /dev/null +++ b/packages/core/src/tools/cron-list.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { CronListTool } from './cron-list.js'; +import { CronScheduler } from '../services/cronScheduler.js'; + +function makeConfig() { + const scheduler = new CronScheduler(); + return { + getCronScheduler: () => scheduler, + _scheduler: scheduler, + } as unknown as import('../config/config.js').Config & { + _scheduler: CronScheduler; + }; +} + +describe('CronListTool', () => { + let config: ReturnType; + let tool: CronListTool; + + beforeEach(() => { + config = makeConfig(); + tool = new CronListTool(config); + }); + + it('has the correct name', () => { + expect(tool.name).toBe('cron_list'); + }); + + it('returns empty message when no jobs', async () => { + const invocation = tool.build({}); + const result = await invocation.execute(new AbortController().signal); + expect(result.error).toBeUndefined(); + expect(result.llmContent).toContain('No active cron jobs'); + }); + + it('lists created jobs', async () => { + config._scheduler.create('*/5 * * * *', 'check build', true); + config._scheduler.create('*/1 * * * *', 'ping', false); + + const invocation = tool.build({}); + const result = await invocation.execute(new AbortController().signal); + expect(result.error).toBeUndefined(); + expect(result.llmContent).toContain('check build'); + expect(result.llmContent).toContain('ping'); + expect(result.llmContent).toContain('recurring'); + expect(result.llmContent).toContain('one-shot'); + expect(result.llmContent).toContain('Active cron jobs (2)'); + }); +}); diff --git a/packages/core/src/tools/cron-list.ts b/packages/core/src/tools/cron-list.ts new file mode 100644 index 000000000..b128938a1 --- /dev/null +++ b/packages/core/src/tools/cron-list.ts @@ -0,0 +1,85 @@ +/** + * cron_list tool — lists all active in-session cron jobs. + */ + +import type { ToolInvocation, ToolResult } from './tools.js'; +import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; +import { ToolNames, ToolDisplayNames } from './tool-names.js'; +import type { Config } from '../config/config.js'; +import { nextFireTime } from '../utils/cronParser.js'; + +export type CronListParams = Record; + +class CronListInvocation extends BaseToolInvocation< + CronListParams, + ToolResult +> { + constructor( + private config: Config, + params: CronListParams, + ) { + super(params); + } + + getDescription(): string { + return 'List all active cron jobs'; + } + + async execute(): Promise { + const scheduler = this.config.getCronScheduler(); + const jobs = scheduler.list(); + + if (jobs.length === 0) { + const result = 'No active cron jobs.'; + return { llmContent: result, returnDisplay: result }; + } + + const now = new Date(); + const lines = jobs.map((job) => { + let nextFire: string; + try { + nextFire = nextFireTime(job.cronExpr, now).toISOString(); + } catch { + nextFire = 'unknown'; + } + const type = job.recurring ? 'recurring' : 'one-shot'; + const created = new Date(job.createdAt).toISOString(); + return [ + `- [${job.id}] ${type}`, + ` Expression: ${job.cronExpr}`, + ` Prompt: ${job.prompt}`, + ` Created: ${created}`, + ` Next fire: ${nextFire}`, + ].join('\n'); + }); + + const result = `Active cron jobs (${jobs.length}):\n${lines.join('\n')}`; + return { llmContent: result, returnDisplay: result }; + } +} + +export class CronListTool extends BaseDeclarativeTool< + CronListParams, + ToolResult +> { + static readonly Name = ToolNames.CRON_LIST; + + constructor(private config: Config) { + super( + CronListTool.Name, + ToolDisplayNames.CRON_LIST, + 'List all active in-session cron jobs, including their IDs, schedules, and next fire times.', + Kind.Other, + { + type: 'object', + properties: {}, + }, + ); + } + + protected createInvocation( + params: CronListParams, + ): ToolInvocation { + return new CronListInvocation(this.config, params); + } +} diff --git a/packages/core/src/tools/tool-names.ts b/packages/core/src/tools/tool-names.ts index 52782c8b6..9edc21508 100644 --- a/packages/core/src/tools/tool-names.ts +++ b/packages/core/src/tools/tool-names.ts @@ -26,6 +26,9 @@ export const ToolNames = { LS: 'list_directory', LSP: 'lsp', ASK_USER_QUESTION: 'ask_user_question', + CRON_CREATE: 'cron_create', + CRON_LIST: 'cron_list', + CRON_DELETE: 'cron_delete', } as const; /** @@ -50,6 +53,9 @@ export const ToolDisplayNames = { LS: 'ListFiles', LSP: 'Lsp', ASK_USER_QUESTION: 'AskUserQuestion', + CRON_CREATE: 'CronCreate', + CRON_LIST: 'CronList', + CRON_DELETE: 'CronDelete', } as const; // Migration from old tool names to new tool names diff --git a/packages/core/src/utils/cronParser.test.ts b/packages/core/src/utils/cronParser.test.ts new file mode 100644 index 000000000..0b5a61cd4 --- /dev/null +++ b/packages/core/src/utils/cronParser.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect } from 'vitest'; +import { matches, nextFireTime, parseCron } from './cronParser.js'; + +describe('parseCron', () => { + it('parses wildcard fields', () => { + const fields = parseCron('* * * * *'); + expect(fields.minute.size).toBe(60); + expect(fields.hour.size).toBe(24); + expect(fields.dayOfMonth.size).toBe(31); + expect(fields.month.size).toBe(12); + expect(fields.dayOfWeek.size).toBe(7); + }); + + it('parses single values', () => { + const fields = parseCron('5 14 1 6 3'); + expect([...fields.minute]).toEqual([5]); + expect([...fields.hour]).toEqual([14]); + expect([...fields.dayOfMonth]).toEqual([1]); + expect([...fields.month]).toEqual([6]); + expect([...fields.dayOfWeek]).toEqual([3]); + }); + + it('parses ranges', () => { + const fields = parseCron('1-5 * * * *'); + expect([...fields.minute].sort((a, b) => a - b)).toEqual([1, 2, 3, 4, 5]); + }); + + it('parses comma lists', () => { + const fields = parseCron('0,15,30,45 * * * *'); + expect([...fields.minute].sort((a, b) => a - b)).toEqual([0, 15, 30, 45]); + }); + + it('parses steps', () => { + const fields = parseCron('*/15 * * * *'); + expect([...fields.minute].sort((a, b) => a - b)).toEqual([0, 15, 30, 45]); + }); + + it('parses range with step', () => { + const fields = parseCron('1-10/3 * * * *'); + expect([...fields.minute].sort((a, b) => a - b)).toEqual([1, 4, 7, 10]); + }); + + it('throws on wrong number of fields', () => { + expect(() => parseCron('* * *')).toThrow('must have exactly 5 fields'); + expect(() => parseCron('* * * * * *')).toThrow( + 'must have exactly 5 fields', + ); + }); + + it('throws on out-of-range values', () => { + expect(() => parseCron('60 * * * *')).toThrow('out of bounds'); + expect(() => parseCron('* 25 * * *')).toThrow('out of bounds'); + expect(() => parseCron('* * 0 * *')).toThrow('out of bounds'); + expect(() => parseCron('* * * 13 *')).toThrow('out of bounds'); + expect(() => parseCron('* * * * 7')).toThrow('out of bounds'); + }); + + it('throws on invalid step', () => { + expect(() => parseCron('*/0 * * * *')).toThrow('Invalid step'); + }); +}); + +describe('matches', () => { + it('matches every-minute cron', () => { + const date = new Date(2025, 0, 15, 10, 30); // Jan 15 2025, 10:30 + expect(matches('* * * * *', date)).toBe(true); + }); + + it('matches specific minute', () => { + const date = new Date(2025, 0, 15, 10, 30); + expect(matches('30 * * * *', date)).toBe(true); + expect(matches('31 * * * *', date)).toBe(false); + }); + + it('matches specific hour and minute', () => { + const date = new Date(2025, 0, 15, 14, 0); + expect(matches('0 14 * * *', date)).toBe(true); + expect(matches('0 13 * * *', date)).toBe(false); + }); + + it('matches day of week', () => { + // Jan 15 2025 is a Wednesday (day 3) + const date = new Date(2025, 0, 15, 10, 0); + expect(matches('* * * * 3', date)).toBe(true); + expect(matches('* * * * 1', date)).toBe(false); + }); + + it('matches every-N-minutes pattern', () => { + const date0 = new Date(2025, 0, 15, 10, 0); + const date5 = new Date(2025, 0, 15, 10, 5); + const date3 = new Date(2025, 0, 15, 10, 3); + expect(matches('*/5 * * * *', date0)).toBe(true); + expect(matches('*/5 * * * *', date5)).toBe(true); + expect(matches('*/5 * * * *', date3)).toBe(false); + }); +}); + +describe('nextFireTime', () => { + it('finds next minute for * * * * *', () => { + const now = new Date(2025, 0, 15, 10, 30, 15); // 10:30:15 + const next = nextFireTime('* * * * *', now); + expect(next.getHours()).toBe(10); + expect(next.getMinutes()).toBe(31); + expect(next.getSeconds()).toBe(0); + }); + + it('finds next match for specific minute', () => { + const now = new Date(2025, 0, 15, 10, 30, 0); + const next = nextFireTime('45 * * * *', now); + expect(next.getHours()).toBe(10); + expect(next.getMinutes()).toBe(45); + }); + + it('rolls to next hour when no match in current hour', () => { + const now = new Date(2025, 0, 15, 10, 50, 0); + const next = nextFireTime('15 * * * *', now); + expect(next.getHours()).toBe(11); + expect(next.getMinutes()).toBe(15); + }); + + it('finds next match for every-5-minutes', () => { + const now = new Date(2025, 0, 15, 10, 31, 0); + const next = nextFireTime('*/5 * * * *', now); + expect(next.getMinutes()).toBe(35); + }); + + it('finds next match for specific hour', () => { + const now = new Date(2025, 0, 15, 10, 30, 0); + const next = nextFireTime('0 14 * * *', now); + expect(next.getHours()).toBe(14); + expect(next.getMinutes()).toBe(0); + expect(next.getDate()).toBe(15); + }); + + it('rolls to next day for past time', () => { + const now = new Date(2025, 0, 15, 15, 0, 0); // 3pm + const next = nextFireTime('0 9 * * *', now); // 9am daily + expect(next.getDate()).toBe(16); + expect(next.getHours()).toBe(9); + }); + + it('returns time strictly after the input', () => { + // Even if `after` is exactly on a match minute, next should be the following match + const now = new Date(2025, 0, 15, 10, 0, 0); // exactly 10:00:00 + const next = nextFireTime('*/5 * * * *', now); + expect(next.getTime()).toBeGreaterThan(now.getTime()); + }); +}); diff --git a/packages/core/src/utils/cronParser.ts b/packages/core/src/utils/cronParser.ts new file mode 100644 index 000000000..44a804f5b --- /dev/null +++ b/packages/core/src/utils/cronParser.ts @@ -0,0 +1,149 @@ +/** + * Minimal 5-field cron expression parser. + * + * Fields: minute (0-59), hour (0-23), day-of-month (1-31), month (1-12), day-of-week (0-6, 0=Sun) + * Supports: *, single values, steps (asterisk/N), ranges (a-b), comma lists (a,b,c) + * No extended syntax (L, W, ?, name aliases). + */ + +interface CronFields { + minute: Set; + hour: Set; + dayOfMonth: Set; + month: Set; + dayOfWeek: Set; +} + +const FIELD_RANGES: Array<[number, number]> = [ + [0, 59], // minute + [0, 23], // hour + [1, 31], // day of month + [1, 12], // month + [0, 6], // day of week +]; + +/** + * Parses a single cron field into a set of matching values. + * Supports: star, single values, steps (star/N), ranges (a-b), comma lists. + */ +function parseField(field: string, min: number, max: number): Set { + const values = new Set(); + + for (const part of field.split(',')) { + const trimmed = part.trim(); + if (!trimmed) { + throw new Error(`Empty field segment in "${field}"`); + } + + // Handle step: */N or range/N or value/N + const stepParts = trimmed.split('/'); + if (stepParts.length > 2) { + throw new Error(`Invalid step expression: "${trimmed}"`); + } + + let rangeStart: number; + let rangeEnd: number; + const base = stepParts[0]!; + + if (base === '*') { + rangeStart = min; + rangeEnd = max; + } else if (base.includes('-')) { + const [startStr, endStr] = base.split('-'); + rangeStart = parseInt(startStr!, 10); + rangeEnd = parseInt(endStr!, 10); + if (isNaN(rangeStart) || isNaN(rangeEnd)) { + throw new Error(`Invalid range: "${base}"`); + } + if (rangeStart < min || rangeEnd > max || rangeStart > rangeEnd) { + throw new Error(`Range ${base} out of bounds [${min}-${max}]`); + } + } else { + const val = parseInt(base, 10); + if (isNaN(val) || val < min || val > max) { + throw new Error(`Value "${base}" out of bounds [${min}-${max}]`); + } + rangeStart = val; + rangeEnd = val; + } + + const step = stepParts.length === 2 ? parseInt(stepParts[1]!, 10) : 1; + if (isNaN(step) || step <= 0) { + throw new Error(`Invalid step: "${stepParts[1]}"`); + } + + for (let i = rangeStart; i <= rangeEnd; i += step) { + values.add(i); + } + } + + return values; +} + +/** + * Parses a 5-field cron expression into structured fields. + * Throws on invalid expressions. + */ +export function parseCron(cronExpr: string): CronFields { + const parts = cronExpr.trim().split(/\s+/); + if (parts.length !== 5) { + throw new Error( + `Cron expression must have exactly 5 fields, got ${parts.length}: "${cronExpr}"`, + ); + } + + return { + minute: parseField(parts[0]!, FIELD_RANGES[0]![0], FIELD_RANGES[0]![1]), + hour: parseField(parts[1]!, FIELD_RANGES[1]![0], FIELD_RANGES[1]![1]), + dayOfMonth: parseField(parts[2]!, FIELD_RANGES[2]![0], FIELD_RANGES[2]![1]), + month: parseField(parts[3]!, FIELD_RANGES[3]![0], FIELD_RANGES[3]![1]), + dayOfWeek: parseField(parts[4]!, FIELD_RANGES[4]![0], FIELD_RANGES[4]![1]), + }; +} + +/** + * Returns true if the given date matches the cron expression. + */ +export function matches(cronExpr: string, date: Date): boolean { + const fields = parseCron(cronExpr); + return ( + fields.minute.has(date.getMinutes()) && + fields.hour.has(date.getHours()) && + fields.dayOfMonth.has(date.getDate()) && + fields.month.has(date.getMonth() + 1) && + fields.dayOfWeek.has(date.getDay()) + ); +} + +/** + * Returns the next fire time after `after` for the given cron expression. + * Scans forward minute-by-minute (up to ~4 years) to find the next match. + */ +export function nextFireTime(cronExpr: string, after: Date): Date { + const fields = parseCron(cronExpr); + + // Start at the next whole minute after `after` + const candidate = new Date(after.getTime()); + candidate.setSeconds(0, 0); + candidate.setMinutes(candidate.getMinutes() + 1); + + // Scan up to 4 years (~2.1M minutes) to avoid infinite loops + const maxIterations = 4 * 366 * 24 * 60; + + for (let i = 0; i < maxIterations; i++) { + if ( + fields.minute.has(candidate.getMinutes()) && + fields.hour.has(candidate.getHours()) && + fields.dayOfMonth.has(candidate.getDate()) && + fields.month.has(candidate.getMonth() + 1) && + fields.dayOfWeek.has(candidate.getDay()) + ) { + return candidate; + } + candidate.setMinutes(candidate.getMinutes() + 1); + } + + throw new Error( + `No matching fire time found within 4 years for: "${cronExpr}"`, + ); +} From c4ae7bf0cd1cfc6b5a7471890ea021ebf0e3addd Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Sun, 29 Mar 2026 00:58:00 +0000 Subject: [PATCH 02/19] test(cli): add cron config mocks to test fixtures - Add isCronDisabled mock returning true - Add getCronScheduler mock returning null This aligns test mocks with the new cron scheduler config interface. Co-authored-by: Qwen-Coder --- packages/cli/src/nonInteractiveCli.test.ts | 2 ++ packages/cli/src/ui/hooks/useGeminiStream.test.tsx | 2 ++ 2 files changed, 4 insertions(+) diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index af3c93113..df9afa9ea 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -144,6 +144,8 @@ describe('runNonInteractive', () => { }), getExperimentalZedIntegration: vi.fn().mockReturnValue(false), isInteractive: vi.fn().mockReturnValue(false), + isCronDisabled: vi.fn().mockReturnValue(true), + getCronScheduler: vi.fn().mockReturnValue(null), } as unknown as Config; mockSettings = { diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index 2234db6bd..94c868c38 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -204,6 +204,8 @@ describe('useGeminiStream', () => { .mockReturnValue(contentGeneratorConfig), getMaxSessionTurns: vi.fn(() => 50), getArenaAgentClient: vi.fn(() => null), + isCronDisabled: vi.fn(() => true), + getCronScheduler: vi.fn(() => null), } as unknown as Config; mockOnDebugMessage = vi.fn(); mockHandleSlashCommand = vi.fn().mockResolvedValue(false); From 99e5a9fbfdced8cc805c59db8868bc05afa83fdc Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Sun, 29 Mar 2026 01:15:14 +0000 Subject: [PATCH 03/19] test(integration): add cron tools integration tests - Test cron_create, cron_list, cron_delete tool registration - Test create-list-delete workflow in single turn - Test one-shot (non-recurring) job creation This validates the cron scheduler tool functionality end-to-end. Co-authored-by: Qwen-Coder --- integration-tests/cron-tools.test.ts | 107 +++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 integration-tests/cron-tools.test.ts diff --git a/integration-tests/cron-tools.test.ts b/integration-tests/cron-tools.test.ts new file mode 100644 index 000000000..f62efba51 --- /dev/null +++ b/integration-tests/cron-tools.test.ts @@ -0,0 +1,107 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js'; + +describe('cron-tools', () => { + let rig: TestRig; + + afterEach(async () => { + if (rig) { + await rig.cleanup(); + } + // Clean up env var if set by disable test + delete process.env['QWEN_CODE_DISABLE_CRON']; + }); + + it('should have cron tools registered', async () => { + rig = new TestRig(); + await rig.setup('cron-tools-registered'); + + const result = await rig.run( + 'Do you have access to tools called cron_create, cron_list, and cron_delete? Reply with just "yes" or "no".', + ); + + validateModelOutput(result, null, 'cron tools registered'); + expect(result.toLowerCase()).toContain('yes'); + }); + + it('should create, list, and delete a cron job in a single turn', async () => { + rig = new TestRig(); + await rig.setup('cron-create-list-delete'); + + const result = await rig.run( + 'Call cron_create with cron_expression "*/5 * * * *", prompt "test ping", recurring true. Then call cron_list. Then delete that job using cron_delete. Then call cron_list again. How many jobs remain? Reply with just the number.', + ); + + const foundCreate = await rig.waitForToolCall('cron_create'); + const foundList = await rig.waitForToolCall('cron_list'); + const foundDelete = await rig.waitForToolCall('cron_delete'); + + if (!foundCreate || !foundList || !foundDelete) { + printDebugInfo(rig, result, { + 'cron_create found': foundCreate, + 'cron_list found': foundList, + 'cron_delete found': foundDelete, + }); + } + + expect(foundCreate, 'Expected cron_create tool call').toBeTruthy(); + expect(foundList, 'Expected cron_list tool call').toBeTruthy(); + expect(foundDelete, 'Expected cron_delete tool call').toBeTruthy(); + + validateModelOutput(result, '0', 'cron create-list-delete'); + }); + + it('should create a one-shot (non-recurring) job', async () => { + rig = new TestRig(); + await rig.setup('cron-one-shot'); + + const result = await rig.run( + 'Do these steps: (1) Call cron_create with cron_expression "*/5 * * * *", prompt "one-shot test", recurring false. (2) Call cron_list. Is the job marked as recurring or one-shot? Remember the answer. (3) Delete all cron jobs. Reply with just "recurring" or "one-shot".', + ); + + const foundCreate = await rig.waitForToolCall('cron_create'); + const foundList = await rig.waitForToolCall('cron_list'); + + if (!foundCreate || !foundList) { + printDebugInfo(rig, result, { + 'cron_create found': foundCreate, + 'cron_list found': foundList, + }); + } + + expect(foundCreate, 'Expected cron_create tool call').toBeTruthy(); + expect(foundList, 'Expected cron_list tool call').toBeTruthy(); + + validateModelOutput(result, 'one-shot', 'cron one-shot'); + }); + + it('should not have cron tools when QWEN_CODE_DISABLE_CRON=1', async () => { + rig = new TestRig(); + await rig.setup('cron-disable-flag'); + + process.env['QWEN_CODE_DISABLE_CRON'] = '1'; + + const result = await rig.run( + 'Do you have access to a tool called cron_create? Reply with just "yes" or "no".', + ); + + validateModelOutput(result, null, 'cron disable flag'); + expect(result.toLowerCase()).toContain('no'); + }); + + it('should exit normally in -p mode when no cron jobs are created', async () => { + rig = new TestRig(); + await rig.setup('cron-no-jobs-exit'); + + // A normal -p call without cron should still exit quickly + const result = await rig.run('What is 2+2? Reply with just the number.'); + + validateModelOutput(result, '4', 'no cron exit'); + }); +}); From 439a1a46e20ebcfa8dca5a3736dcfac53e240524 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Sun, 29 Mar 2026 02:25:28 +0000 Subject: [PATCH 04/19] feat(cron): make cron tools opt-in via experimental settings Change cron/loop tools from opt-out to opt-in. Cron tools are now disabled by default and can be enabled via: - settings.json: { "experimental": { "cron": true } } - Environment variable: QWEN_CODE_ENABLE_CRON=1 This ensures experimental features are explicitly enabled by users who want to try them. Co-authored-by: Qwen-Coder --- integration-tests/cron-tools.test.ts | 62 ++++++++++++------- packages/cli/src/config/config.ts | 1 + packages/cli/src/config/settingsSchema.ts | 15 ++++- packages/cli/src/nonInteractiveCli.test.ts | 2 +- packages/cli/src/nonInteractiveCli.ts | 2 +- .../cli/src/ui/hooks/useGeminiStream.test.tsx | 2 +- packages/cli/src/ui/hooks/useGeminiStream.ts | 2 +- packages/core/src/config/config.ts | 11 +++- .../schemas/settings.schema.json | 10 ++- 9 files changed, 75 insertions(+), 32 deletions(-) diff --git a/integration-tests/cron-tools.test.ts b/integration-tests/cron-tools.test.ts index f62efba51..6a69ffa30 100644 --- a/integration-tests/cron-tools.test.ts +++ b/integration-tests/cron-tools.test.ts @@ -14,13 +14,15 @@ describe('cron-tools', () => { if (rig) { await rig.cleanup(); } - // Clean up env var if set by disable test - delete process.env['QWEN_CODE_DISABLE_CRON']; + // Clean up env vars + delete process.env['QWEN_CODE_ENABLE_CRON']; }); - it('should have cron tools registered', async () => { + it('should have cron tools registered when enabled via settings', async () => { rig = new TestRig(); - await rig.setup('cron-tools-registered'); + await rig.setup('cron-tools-registered', { + settings: { experimental: { cron: true } }, + }); const result = await rig.run( 'Do you have access to tools called cron_create, cron_list, and cron_delete? Reply with just "yes" or "no".', @@ -30,9 +32,37 @@ describe('cron-tools', () => { expect(result.toLowerCase()).toContain('yes'); }); + it('should have cron tools registered when enabled via env var', async () => { + rig = new TestRig(); + await rig.setup('cron-tools-env-var'); + + process.env['QWEN_CODE_ENABLE_CRON'] = '1'; + + const result = await rig.run( + 'Do you have access to tools called cron_create, cron_list, and cron_delete? Reply with just "yes" or "no".', + ); + + validateModelOutput(result, null, 'cron tools via env var'); + expect(result.toLowerCase()).toContain('yes'); + }); + + it('should NOT have cron tools by default', async () => { + rig = new TestRig(); + await rig.setup('cron-tools-disabled-by-default'); + + const result = await rig.run( + 'Do you have access to a tool called cron_create? Reply with just "yes" or "no".', + ); + + validateModelOutput(result, null, 'cron disabled by default'); + expect(result.toLowerCase()).toContain('no'); + }); + it('should create, list, and delete a cron job in a single turn', async () => { rig = new TestRig(); - await rig.setup('cron-create-list-delete'); + await rig.setup('cron-create-list-delete', { + settings: { experimental: { cron: true } }, + }); const result = await rig.run( 'Call cron_create with cron_expression "*/5 * * * *", prompt "test ping", recurring true. Then call cron_list. Then delete that job using cron_delete. Then call cron_list again. How many jobs remain? Reply with just the number.', @@ -59,7 +89,9 @@ describe('cron-tools', () => { it('should create a one-shot (non-recurring) job', async () => { rig = new TestRig(); - await rig.setup('cron-one-shot'); + await rig.setup('cron-one-shot', { + settings: { experimental: { cron: true } }, + }); const result = await rig.run( 'Do these steps: (1) Call cron_create with cron_expression "*/5 * * * *", prompt "one-shot test", recurring false. (2) Call cron_list. Is the job marked as recurring or one-shot? Remember the answer. (3) Delete all cron jobs. Reply with just "recurring" or "one-shot".', @@ -81,23 +113,11 @@ describe('cron-tools', () => { validateModelOutput(result, 'one-shot', 'cron one-shot'); }); - it('should not have cron tools when QWEN_CODE_DISABLE_CRON=1', async () => { - rig = new TestRig(); - await rig.setup('cron-disable-flag'); - - process.env['QWEN_CODE_DISABLE_CRON'] = '1'; - - const result = await rig.run( - 'Do you have access to a tool called cron_create? Reply with just "yes" or "no".', - ); - - validateModelOutput(result, null, 'cron disable flag'); - expect(result.toLowerCase()).toContain('no'); - }); - it('should exit normally in -p mode when no cron jobs are created', async () => { rig = new TestRig(); - await rig.setup('cron-no-jobs-exit'); + await rig.setup('cron-no-jobs-exit', { + settings: { experimental: { cron: true } }, + }); // A normal -p call without cron should still exit quickly const result = await rig.run('What is 2+2? Reply with just the number.'); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index fcc33f76a..097efa24d 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -1089,6 +1089,7 @@ export async function loadCliConfig( maxSessionTurns: argv.maxSessionTurns ?? settings.model?.maxSessionTurns ?? -1, experimentalZedIntegration: argv.acp || argv.experimentalAcp || false, + cronEnabled: settings.experimental?.cron ?? false, listExtensions: argv.listExtensions || false, overrideExtensions: overrideExtensions || argv.extensions, noBrowser: !!process.env['NO_BROWSER'], diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index d2cf5081c..6006b3add 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1594,9 +1594,20 @@ const SETTINGS_SCHEMA = { category: 'Experimental', requiresRestart: true, default: {}, - description: 'Setting to enable experimental features', + description: 'Settings to enable experimental features.', showInDialog: false, - properties: {}, + properties: { + cron: { + type: 'boolean', + label: 'Enable Cron/Loop Tools', + category: 'Experimental', + requiresRestart: true, + default: false, + description: + 'Enable in-session cron/loop tools (experimental). When enabled, the model can create recurring prompts using cron_create, cron_list, and cron_delete tools. Can also be enabled via QWEN_CODE_ENABLE_CRON=1 environment variable.', + showInDialog: true, + }, + }, }, } as const satisfies SettingsSchema; diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index df9afa9ea..8bd34ca22 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -144,7 +144,7 @@ describe('runNonInteractive', () => { }), getExperimentalZedIntegration: vi.fn().mockReturnValue(false), isInteractive: vi.fn().mockReturnValue(false), - isCronDisabled: vi.fn().mockReturnValue(true), + isCronEnabled: vi.fn().mockReturnValue(false), getCronScheduler: vi.fn().mockReturnValue(null), } as unknown as Config; diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 69f0f1fbf..8c0b7c28d 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -372,7 +372,7 @@ export async function runNonInteractive( currentMessages = [{ role: 'user', parts: toolResponseParts }]; } else { // No more tool calls — check if cron jobs are keeping us alive - const scheduler = config.isCronDisabled() + const scheduler = !config.isCronEnabled() ? null : config.getCronScheduler(); if (scheduler && scheduler.size > 0) { diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index 94c868c38..29d0c4690 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -204,7 +204,7 @@ describe('useGeminiStream', () => { .mockReturnValue(contentGeneratorConfig), getMaxSessionTurns: vi.fn(() => 50), getArenaAgentClient: vi.fn(() => null), - isCronDisabled: vi.fn(() => true), + isCronEnabled: vi.fn(() => false), getCronScheduler: vi.fn(() => null), } as unknown as Config; mockOnDebugMessage = vi.fn(); diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 8e3388b3a..9b4b7553c 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -1643,7 +1643,7 @@ export const useGeminiStream = ( // Start the scheduler on mount, stop on unmount useEffect(() => { - if (config.isCronDisabled()) return; + if (!config.isCronEnabled()) return; const scheduler = config.getCronScheduler(); scheduler.start((job: { prompt: string }) => { cronQueueRef.current.push(job.prompt); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 007b22ec2..d9867310c 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -370,6 +370,7 @@ export interface ConfigParameters { maxSessionTurns?: number; sessionTokenLimit?: number; experimentalZedIntegration?: boolean; + cronEnabled?: boolean; listExtensions?: boolean; overrideExtensions?: string[]; allowedMcpServers?: string[]; @@ -557,6 +558,7 @@ export class Config { private readonly cliVersion?: string; private readonly experimentalZedIntegration: boolean = false; + private readonly cronEnabled: boolean = false; private readonly chatRecordingEnabled: boolean; private readonly loadMemoryFromIncludeDirectories: boolean = false; private readonly importFormat: 'tree' | 'flat'; @@ -680,6 +682,7 @@ export class Config { this.sessionTokenLimit = params.sessionTokenLimit ?? -1; this.experimentalZedIntegration = params.experimentalZedIntegration ?? false; + this.cronEnabled = params.cronEnabled ?? false; this.listExtensions = params.listExtensions ?? false; this.overrideExtensions = params.overrideExtensions; this.noBrowser = params.noBrowser ?? false; @@ -1687,8 +1690,10 @@ export class Config { return this.cronScheduler; } - isCronDisabled(): boolean { - return process.env['QWEN_CODE_DISABLE_CRON'] === '1'; + isCronEnabled(): boolean { + // Cron is experimental and opt-in: enabled via settings or env var + if (process.env['QWEN_CODE_ENABLE_CRON'] === '1') return true; + return this.cronEnabled; } getEnableRecursiveFileSearch(): boolean { @@ -2211,7 +2216,7 @@ export class Config { } // Register cron tools unless disabled - if (!this.isCronDisabled()) { + if (this.isCronEnabled()) { await registerCoreTool(CronCreateTool, this); await registerCoreTool(CronListTool, this); await registerCoreTool(CronDeleteTool, this); diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index c7f53048e..b540147b4 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -1445,9 +1445,15 @@ } }, "experimental": { - "description": "Setting to enable experimental features", + "description": "Settings to enable experimental features.", "type": "object", - "properties": {} + "properties": { + "cron": { + "description": "Enable in-session cron/loop tools (experimental). When enabled, the model can create recurring prompts using cron_create, cron_list, and cron_delete tools. Can also be enabled via QWEN_CODE_ENABLE_CRON=1 environment variable.", + "type": "boolean", + "default": false + } + } }, "$version": { "type": "number", From 57cf2b0bf2024075c73aaeaf2dd81186f4ec8d5d Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Sun, 29 Mar 2026 11:05:37 +0800 Subject: [PATCH 05/19] refactor(cron): rename cron_expression param to cron and enhance documentation - Rename cron_expression parameter to cron for brevity across CronCreateTool - Expand tool description with comprehensive usage guidance for one-shot and recurring tasks - Add best practices for avoiding :00/:30 minute marks to reduce API load spikes - Document 3-day auto-expiration for recurring jobs and session-only lifetime - Add additionalProperties: false to all cron tool schemas for stricter validation - Update integration tests and loop SKILL to use renamed parameter This improves the developer experience with clearer parameter names and provides users with detailed guidance on scheduling patterns and runtime behavior. Co-authored-by: Qwen-Coder --- integration-tests/cron-tools.test.ts | 4 +- .../core/src/skills/bundled/loop/SKILL.md | 2 +- packages/core/src/tools/cron-create.test.ts | 10 ++-- packages/core/src/tools/cron-create.ts | 49 ++++++++++++------- packages/core/src/tools/cron-delete.ts | 5 +- packages/core/src/tools/cron-list.ts | 3 +- 6 files changed, 44 insertions(+), 29 deletions(-) diff --git a/integration-tests/cron-tools.test.ts b/integration-tests/cron-tools.test.ts index 6a69ffa30..485eec1f2 100644 --- a/integration-tests/cron-tools.test.ts +++ b/integration-tests/cron-tools.test.ts @@ -65,7 +65,7 @@ describe('cron-tools', () => { }); const result = await rig.run( - 'Call cron_create with cron_expression "*/5 * * * *", prompt "test ping", recurring true. Then call cron_list. Then delete that job using cron_delete. Then call cron_list again. How many jobs remain? Reply with just the number.', + 'Call cron_create with cron "*/5 * * * *", prompt "test ping", recurring true. Then call cron_list. Then delete that job using cron_delete. Then call cron_list again. How many jobs remain? Reply with just the number.', ); const foundCreate = await rig.waitForToolCall('cron_create'); @@ -94,7 +94,7 @@ describe('cron-tools', () => { }); const result = await rig.run( - 'Do these steps: (1) Call cron_create with cron_expression "*/5 * * * *", prompt "one-shot test", recurring false. (2) Call cron_list. Is the job marked as recurring or one-shot? Remember the answer. (3) Delete all cron jobs. Reply with just "recurring" or "one-shot".', + 'Do these steps: (1) Call cron_create with cron "*/5 * * * *", prompt "one-shot test", recurring false. (2) Call cron_list. Is the job marked as recurring or one-shot? Remember the answer. (3) Delete all cron jobs. Reply with just "recurring" or "one-shot".', ); const foundCreate = await rig.waitForToolCall('cron_create'); diff --git a/packages/core/src/skills/bundled/loop/SKILL.md b/packages/core/src/skills/bundled/loop/SKILL.md index 0b0969eca..074ff02ae 100644 --- a/packages/core/src/skills/bundled/loop/SKILL.md +++ b/packages/core/src/skills/bundled/loop/SKILL.md @@ -29,7 +29,7 @@ You are setting up a recurring in-session loop. Parse the user's input to extrac 2. Convert the interval to a cron expression 3. Append to the prompt: `\n\nBe concise. If nothing has changed, reply with a single short sentence.` 4. Call `cron_create` with: - - `cron_expression`: the computed cron expression + - `cron`: the computed cron expression - `prompt`: the extracted prompt with the conciseness instruction appended - `recurring`: true 5. Confirm to the user: "Loop created — I'll [description] every [interval]." diff --git a/packages/core/src/tools/cron-create.test.ts b/packages/core/src/tools/cron-create.test.ts index a44eae733..74145f424 100644 --- a/packages/core/src/tools/cron-create.test.ts +++ b/packages/core/src/tools/cron-create.test.ts @@ -27,7 +27,7 @@ describe('CronCreateTool', () => { it('creates a recurring job by default', async () => { const invocation = tool.build({ - cron_expression: '*/5 * * * *', + cron: '*/5 * * * *', prompt: 'check status', }); const result = await invocation.execute(new AbortController().signal); @@ -39,7 +39,7 @@ describe('CronCreateTool', () => { it('creates a one-shot job when recurring=false', async () => { const invocation = tool.build({ - cron_expression: '*/1 * * * *', + cron: '*/1 * * * *', prompt: 'once', recurring: false, }); @@ -53,7 +53,7 @@ describe('CronCreateTool', () => { it('returns error for invalid cron expression', async () => { const invocation = tool.build({ - cron_expression: 'bad cron', + cron: 'bad cron', prompt: 'fail', }); const result = await invocation.execute(new AbortController().signal); @@ -61,9 +61,7 @@ describe('CronCreateTool', () => { }); it('validates required params', () => { - expect(() => - tool.build({ cron_expression: '*/1 * * * *' } as never), - ).toThrow(); + expect(() => tool.build({ cron: '*/1 * * * *' } as never)).toThrow(); expect(() => tool.build({ prompt: 'test' } as never)).toThrow(); }); }); diff --git a/packages/core/src/tools/cron-create.ts b/packages/core/src/tools/cron-create.ts index cdfaba286..94edff3b3 100644 --- a/packages/core/src/tools/cron-create.ts +++ b/packages/core/src/tools/cron-create.ts @@ -9,7 +9,7 @@ import type { Config } from '../config/config.js'; import { nextFireTime } from '../utils/cronParser.js'; export interface CronCreateParams { - cron_expression: string; + cron: string; prompt: string; recurring?: boolean; } @@ -28,7 +28,7 @@ class CronCreateInvocation extends BaseToolInvocation< getDescription(): string { const recurrence = this.params.recurring !== false ? 'recurring' : 'one-shot'; - return `Create ${recurrence} cron job: ${this.params.cron_expression}`; + return `Create ${recurrence} cron job: ${this.params.cron}`; } async execute(): Promise { @@ -37,12 +37,12 @@ class CronCreateInvocation extends BaseToolInvocation< try { const job = scheduler.create( - this.params.cron_expression, + this.params.cron, this.params.prompt, recurring, ); - const next = nextFireTime(this.params.cron_expression, new Date()); + const next = nextFireTime(this.params.cron, new Date()); const result = [ `Created ${recurring ? 'recurring' : 'one-shot'} cron job.`, ` ID: ${job.id}`, @@ -76,34 +76,49 @@ export class CronCreateTool extends BaseDeclarativeTool< super( CronCreateTool.Name, ToolDisplayNames.CRON_CREATE, - 'Create a new in-session cron job that fires a prompt on a schedule. ' + - 'The job runs within the current session and is gone when the session ends. ' + - 'Use standard 5-field cron expressions (minute hour day-of-month month day-of-week). ' + - 'Examples: "*/5 * * * *" (every 5 min), "0 */2 * * *" (every 2 hours), "*/1 * * * *" (every minute).', + 'Schedule a prompt to be enqueued at a future time. Use for both recurring schedules and one-shot reminders.\n\n' + + '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' + + '## One-shot tasks (recurring: false)\n\n' + + 'For "remind me at X" or "at