| /** |
| * Unified Automation tool — single tool with mode parameter. |
| * |
| * Modes: create, delete, list, pause, resume |
| * Kinds: heartbeat (session-internal polling) | cron (standalone scheduled runs) |
| * |
| * Follows Codex Desktop's pattern: one tool, parameters decide behavior. |
| */ |
| |
| import { z } from 'zod'; |
| import type { MakaTool } from './tool-runtime.js'; |
| import type { AutomationManager, AutomationDefinition } from './automation-state.js'; |
| |
| export const AUTOMATION_TOOL_NAME = 'Automation'; |
| |
| export interface AutomationToolDeps { |
| automationManager: AutomationManager; |
| onAutomationChange?: () => void; |
| /** Whether the host can run cron (fresh-session) automations. When false, the |
| * cron kind is not advertised and is rejected at creation. */ |
| cronEnabled?: boolean; |
| } |
| |
| const scheduleSchema = z.union([ |
| z.object({ |
| type: z.literal('cron'), |
| expression: z |
| .string() |
| .min(9) |
| .max(100) |
| .describe( |
| '5-field cron expression: "minute hour day-of-month month day-of-week". Example: "*/5 * * * *" = every 5 min, "0 9 * * 1-5" = weekdays at 9am.', |
| ), |
| }), |
| z.object({ |
| type: z.literal('interval'), |
| seconds: z |
| .number() |
| .int() |
| .min(10) |
| .max(86400) |
| .describe('Repeat interval in seconds (10s to 24h).'), |
| }), |
| z.object({ |
| type: z.literal('once'), |
| delay_seconds: z |
| .number() |
| .int() |
| .min(5) |
| .max(86400) |
| .describe('One-shot delay in seconds (5s to 24h). Fires once then auto-completes.'), |
| }), |
| ]); |
| |
| // A SINGLE top-level object schema (Anthropic tool input_schema.type must be |
| // "object" — a discriminated union serializes as anyOf with no top-level type |
| // and the API rejects it). Per-mode fields are optional here and validated in |
| // impl(). mode selects the operation. |
| function makeAutomationSchema(kindSchema: z.ZodType) { |
| return z.object({ |
| mode: z |
| .enum(['create', 'delete', 'list', 'pause', 'resume']) |
| .describe( |
| "Operation: create a new automation, delete/pause/resume one by id, or list this session's automations.", |
| ), |
| kind: kindSchema.optional(), |
| name: z |
| .string() |
| .trim() |
| .min(1) |
| .max(100) |
| .optional() |
| .describe('[create] Short human-readable name.'), |
| prompt: z |
| .string() |
| .trim() |
| .min(1) |
| .max(2000) |
| .optional() |
| .describe('[create] The prompt to execute on each fire.'), |
| schedule: scheduleSchema |
| .optional() |
| .describe( |
| '[create] When to fire. Use "interval" for simple repeats, "cron" for complex schedules, "once" for one-shot.', |
| ), |
| max_fires: z |
| .number() |
| .int() |
| .min(1) |
| .max(10000) |
| .optional() |
| .describe( |
| '[create] Maximum fires before auto-completing. Omit for unlimited (7-day expiry still applies).', |
| ), |
| durable: z |
| .boolean() |
| .optional() |
| .describe( |
| '[create] When true, persists across app restarts. Cron defaults to true (standalone scheduled task); heartbeat defaults to false (bound to this session).', |
| ), |
| id: z.string().min(1).max(64).optional().describe('[delete/pause/resume] Automation id.'), |
| }); |
| } |
| |
| const AUTOMATION_SCHEMA_WITH_CRON = makeAutomationSchema( |
| z |
| .enum(['heartbeat', 'cron']) |
| .describe( |
| '[create] heartbeat = resume into current session (polling/monitoring). cron = create fresh session each run (standalone scheduled tasks).', |
| ), |
| ); |
| const AUTOMATION_SCHEMA_HEARTBEAT_ONLY = makeAutomationSchema( |
| z |
| .enum(['heartbeat']) |
| .describe( |
| '[create] heartbeat = resume into current session. This host supports heartbeat only.', |
| ), |
| ); |
| |
| // Type from the broadest (cron-enabled) schema so kind can be 'heartbeat'|'cron'. |
| type AutomationInput = z.infer<typeof AUTOMATION_SCHEMA_WITH_CRON>; |
| |
| export function buildAutomationTool(deps: AutomationToolDeps): MakaTool<AutomationInput, string> { |
| const cronEnabled = deps.cronEnabled === true; |
| return { |
| name: AUTOMATION_TOOL_NAME, |
| displayName: 'Automation', |
| description: |
| 'Create, manage, and list recurring automations. ' + |
| 'Use kind "heartbeat" for session-internal polling (resumes into this conversation). ' + |
| (cronEnabled |
| ? 'Use kind "cron" for standalone scheduled tasks (creates a fresh session each run). ' |
| : '') + |
| 'Automations auto-expire after 7 days unless deleted earlier.', |
| parameters: cronEnabled ? AUTOMATION_SCHEMA_WITH_CRON : AUTOMATION_SCHEMA_HEARTBEAT_ONLY, |
| permissionRequired: false, |
| impl: (input, ctx) => { |
| let result: string; |
| switch (input.mode) { |
| case 'create': |
| result = handleCreate(deps, input, ctx.sessionId, cronEnabled); |
| break; |
| case 'delete': |
| result = handleById(input, (id) => |
| deps.automationManager.delete(id, ctx.sessionId) |
| ? `Automation "${id}" deleted.` |
| : `Automation "${id}" not found or not owned by this session.`, |
| ); |
| break; |
| case 'list': |
| return handleList(deps, ctx.sessionId); |
| case 'pause': { |
| result = handleById(input, (id) => { |
| const r = deps.automationManager.pause(id, ctx.sessionId); |
| return r |
| ? `Automation "${r.name}" paused. Use mode "resume" to reactivate.` |
| : `Cannot pause "${id}": not found, not owned, or not active.`; |
| }); |
| break; |
| } |
| case 'resume': { |
| result = handleById(input, (id) => { |
| const r = deps.automationManager.resume(id, ctx.sessionId); |
| if (r) { |
| return `Automation "${r.name}" resumed. Next fire: ${r.nextFireAt ? new Date(r.nextFireAt).toLocaleString() : 'N/A'}`; |
| } |
| // Distinguish a spent fire budget from other resume failures so the |
| // agent doesn't keep retrying a cap that can never be revived. |
| const existing = deps.automationManager.get(id); |
| if (existing && existing.status === 'paused') { |
| const spent = |
| (existing.maxFires != null && existing.fireCount >= existing.maxFires) || |
| (existing.schedule.type === 'once' && existing.fireCount > 0); |
| if (spent) { |
| return `Cannot resume "${id}": its fire budget is exhausted (fired ${existing.fireCount}${existing.maxFires != null ? `/${existing.maxFires}` : ''} time(s)). Create a new automation instead.`; |
| } |
| } |
| return `Cannot resume "${id}": not found, not owned, or not paused.`; |
| }); |
| break; |
| } |
| } |
| deps.onAutomationChange?.(); |
| return result; |
| }, |
| }; |
| } |
| |
| function handleById(input: AutomationInput, run: (id: string) => string): string { |
| if (!input.id) return 'Error: "id" is required for delete/pause/resume.'; |
| return run(input.id); |
| } |
| |
| function handleCreate( |
| deps: AutomationToolDeps, |
| input: AutomationInput, |
| sessionId: string, |
| cronEnabled: boolean, |
| ): string { |
| if (!input.kind) return 'Error: "kind" is required for create.'; |
| if (!input.name) return 'Error: "name" is required for create.'; |
| if (!input.prompt) return 'Error: "prompt" is required for create.'; |
| if (!input.schedule) return 'Error: "schedule" is required for create.'; |
| if (input.kind === 'cron' && !cronEnabled) { |
| return 'Error: cron automations are not supported on this host. Use kind "heartbeat".'; |
| } |
| const schedule = |
| input.schedule.type === 'once' |
| ? { type: 'once' as const, delaySeconds: input.schedule.delay_seconds } |
| : input.schedule; |
| |
| const result = deps.automationManager.create({ |
| kind: input.kind as 'heartbeat' | 'cron', |
| name: input.name, |
| prompt: input.prompt, |
| sessionId, |
| schedule, |
| maxFires: input.max_fires, |
| durable: input.durable, |
| }); |
| |
| if ('error' in result) { |
| return `Error: ${result.error}`; |
| } |
| |
| const scheduleDesc = describeSchedule(result.schedule); |
| return [ |
| `Automation created: "${result.name}" (${result.kind}${result.durable ? ', durable' : ''})`, |
| `ID: ${result.id}`, |
| `Schedule: ${scheduleDesc}`, |
| `Next fire: ${result.nextFireAt ? new Date(result.nextFireAt).toLocaleString() : 'N/A'}`, |
| result.kind === 'heartbeat' |
| ? 'Fires into this session. Stops when session ends or after 7 days.' |
| : 'Creates a fresh session each run. Expires after 7 days.', |
| ].join('\n'); |
| } |
| |
| function handleList(deps: AutomationToolDeps, sessionId: string): string { |
| // Includes this session's automations plus every durable (app-global) one, |
| // so persisted cron jobs stay queryable and manageable after a restart even |
| // from a fresh session. |
| const automations = deps.automationManager.listVisibleForSession(sessionId); |
| if (automations.length === 0) return 'No automations for this session.'; |
| |
| return automations.map((a) => formatAutomation(a)).join('\n---\n'); |
| } |
| |
| function formatAutomation(a: AutomationDefinition): string { |
| // Fire attempts (fireCount) + idle-gate deferrals are model-facing |
| // observability, mirroring the old CronList's fire_attempts/deferred_fires. |
| const deferred = a.deferredFireCount ?? 0; |
| const lines = [ |
| `[${a.status.toUpperCase()}] ${a.name} (${a.kind}${a.durable ? ', durable' : ''})`, |
| ` ID: ${a.id}`, |
| ` Schedule: ${describeSchedule(a.schedule)}`, |
| ` Fires: ${a.fireCount}${a.maxFires ? `/${a.maxFires}` : ''}${deferred > 0 ? ` (deferred ${deferred} attempt(s) while busy)` : ''}`, |
| ]; |
| if (a.nextFireAt) lines.push(` Next: ${new Date(a.nextFireAt).toLocaleString()}`); |
| if (a.lastFireAt) lines.push(` Last: ${new Date(a.lastFireAt).toLocaleString()}`); |
| if (a.lastError) lines.push(` Error: ${a.lastError}`); |
| if (a.consecutiveFailures > 0) lines.push(` Consecutive failures: ${a.consecutiveFailures}`); |
| return lines.join('\n'); |
| } |
| |
| function describeSchedule(schedule: AutomationDefinition['schedule']): string { |
| switch (schedule.type) { |
| case 'cron': |
| return `cron "${schedule.expression}"`; |
| case 'interval': |
| return `every ${schedule.seconds}s`; |
| case 'once': |
| return `once after ${schedule.delaySeconds}s`; |
| } |
| } |