blob: 326482d681d3aac602c7976ee241228c01e3f357 [file]
/**
* 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 {
AUTOMATION_CRON_EXPRESSION_LIMIT,
AUTOMATION_CRON_EXPRESSION_MAX_CODE_UNITS,
AUTOMATION_NAME_LIMIT,
AUTOMATION_NAME_MAX_CODE_UNITS,
AUTOMATION_PROMPT_LIMIT,
AUTOMATION_PROMPT_MAX_CODE_UNITS,
isAutomationTextWithinLimit,
} from '@maka/core';
import type { AutomationStatus } from '@maka/core';
import type { MakaTool } from './tool-runtime.js';
import type { AutomationDefinition } from './automation-state.js';
export const AUTOMATION_TOOL_NAME = 'Automation';
export const AUTOMATION_MODEL_LIST_MAX_ITEMS = 100;
export interface AutomationToolAuthority {
create(input: {
kind: AutomationDefinition['kind'];
name: string;
prompt: string;
sessionId: string;
schedule: AutomationDefinition['schedule'];
maxFires?: number;
durable?: boolean;
}): AutomationDefinition | { error: string } | Promise<AutomationDefinition | { error: string }>;
delete(id: string, sessionId: string): boolean | Promise<boolean>;
pause(
id: string,
sessionId: string,
): AutomationDefinition | undefined | Promise<AutomationDefinition | undefined>;
resume(
id: string,
sessionId: string,
): AutomationDefinition | undefined | Promise<AutomationDefinition | undefined>;
get(
id: string,
sessionId: string,
): AutomationDefinition | undefined | Promise<AutomationDefinition | undefined>;
listVisibleForSession(
sessionId: string,
): readonly AutomationDefinition[] | Promise<readonly AutomationDefinition[]>;
}
export interface AutomationAuthorityToolDeps {
readonly authority: AutomationToolAuthority;
readonly cronEnabled?: boolean;
}
const scheduleSchema = z.union([
z.object({
type: z.literal('cron'),
expression: z
.string()
.min(9)
.max(AUTOMATION_CRON_EXPRESSION_MAX_CODE_UNITS)
.refine((value) => isAutomationTextWithinLimit(value, AUTOMATION_CRON_EXPRESSION_LIMIT), {
message: 'Cron expression exceeds the Automation text limit.',
})
.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(AUTOMATION_NAME_MAX_CODE_UNITS)
.refine(
(value) => isAutomationTextWithinLimit(value, AUTOMATION_NAME_LIMIT, { nonblank: true }),
{ message: 'Automation name exceeds the text limit.' },
)
.optional()
.describe('[create] Short human-readable name.'),
prompt: z
.string()
.trim()
.min(1)
.max(AUTOMATION_PROMPT_MAX_CODE_UNITS)
.refine(
(value) => isAutomationTextWithinLimit(value, AUTOMATION_PROMPT_LIMIT, { nonblank: true }),
{ message: 'Automation prompt exceeds the text limit.' },
)
.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 buildAutomationAuthorityTool(
deps: AutomationAuthorityToolDeps,
): 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,
impl: async (input, ctx) => {
let result: string;
switch (input.mode) {
case 'create':
result = await handleCreate(deps.authority, input, ctx.sessionId, cronEnabled);
break;
case 'delete':
result = await handleById(input, async (id) =>
(await deps.authority.delete(id, ctx.sessionId))
? `Automation "${id}" deleted.`
: `Automation "${id}" not found or not owned by this session.`,
);
break;
case 'list':
return handleList(deps.authority, ctx.sessionId);
case 'pause': {
result = await handleById(input, async (id) => {
const r = await deps.authority.pause(id, ctx.sessionId);
return r
? `Automation "${r.name}" paused. Use mode "resume" to reactivate.`
: await explainManageFailure(deps.authority, id, ctx.sessionId, 'pause');
});
break;
}
case 'resume': {
result = await handleById(input, async (id) => {
const r = await deps.authority.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 = await deps.authority.get(id, ctx.sessionId);
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 await explainManageFailure(deps.authority, id, ctx.sessionId, 'resume');
});
break;
}
}
return result;
},
};
}
async function handleById(
input: AutomationInput,
run: (id: string) => Promise<string>,
): Promise<string> {
if (!input.id) return 'Error: "id" is required for delete/pause/resume.';
return await run(input.id);
}
/**
* What each status means for pause/resume, in one place.
*
* The two facts the refusal text needs — which verb the status admits, and
* whether the automation is past reviving — used to be hand-rolled literal
* comparisons, so a fifth AutomationStatus would have compiled clean and
* quietly told the model that a live automation was beyond repair. A total
* Record makes the compiler ask for the answer instead.
*/
const AUTOMATION_STATUS_FACTS: Record<
AutomationStatus,
{ readonly admits: 'pause' | 'resume' | null; readonly terminal: boolean }
> = {
active: { admits: 'pause', terminal: false },
paused: { admits: 'resume', terminal: false },
completed: { admits: null, terminal: true },
expired: { admits: null, terminal: true },
};
/**
* Say what actually blocked pause/resume, and what to do about it.
*
* "not found, not owned, or not active" folds three independent causes into one
* sentence and names a next action for none of them. Two of the three are
* answered by mode "list"; the third is a status the model can neither see nor
* change by sending the same call again, so it has to be said out loud.
*
* The authority only exposes automations this session may manage, so a missing
* id and another session's id are genuinely indistinguishable here and share
* one message — but that message points at mode "list", which settles both.
*
* Status is the only cause this function can actually read. An authority may
* refuse for reasons that live outside the automation — the host coordinator
* turns a retiring or archived session into the same empty result as a
* wrong-status automation — and in that case the status on record still admits
* the verb. Saying "it is active, so it cannot be paused" there would be a
* verdict on something never checked, so that case says the cause was not
* reported rather than inventing one. Every branch lands on mode "list", which
* reads state and so survives the session conditions that refuse mutations.
*/
async function explainManageFailure(
authority: AutomationToolAuthority,
id: string,
sessionId: string,
verb: 'pause' | 'resume',
): Promise<string> {
const listHint = 'Use mode "list" to see the automations you can manage and their ids.';
const existing = await authority.get(id, sessionId);
if (!existing) {
return `Cannot ${verb} "${id}": this session has no automation with that id. ${listHint}`;
}
const facts = AUTOMATION_STATUS_FACTS[existing.status];
if (facts.admits === verb) {
return `Cannot ${verb} "${id}": it is ${existing.status}, which is the status ${verb} needs, so its status is not what refused. The reason was not reported here. Use mode "list" to re-read its current state.`;
}
if (verb === 'pause') {
return `Cannot pause "${id}": it is ${existing.status}, and only an active automation can be paused.`;
}
return `Cannot resume "${id}": it is ${existing.status}, and only a paused automation can be resumed.${
facts.terminal ? ' Create a new automation instead.' : ''
}`;
}
async function handleCreate(
authority: AutomationToolAuthority,
input: AutomationInput,
sessionId: string,
cronEnabled: boolean,
): Promise<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 = await authority.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');
}
async function handleList(authority: AutomationToolAuthority, sessionId: string): Promise<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 = await authority.listVisibleForSession(sessionId);
if (automations.length === 0) return 'No automations for this session.';
const visible = automations.slice(0, AUTOMATION_MODEL_LIST_MAX_ITEMS);
const formatted = visible.map((a) => formatAutomation(a));
if (automations.length > visible.length) {
formatted.push(`${automations.length - visible.length} additional automations omitted.`);
}
return formatted.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`;
}
}