agent-accelerator 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +149 -57
  2. package/bunfig.toml +1 -0
  3. package/package.json +2 -3
  4. package/src/agent/agent.ts +414 -118
  5. package/src/agent/context.ts +11 -16
  6. package/src/agent/delegation.ts +111 -274
  7. package/src/agent/loop.ts +327 -99
  8. package/src/agent/subagent.ts +4 -12
  9. package/src/agent/worker.ts +435 -0
  10. package/src/index.ts +58 -5
  11. package/src/models/catalog-cache.ts +65 -25
  12. package/src/models/catalog.ts +47 -2
  13. package/src/providers/google.ts +338 -81
  14. package/src/providers/openai-compat.ts +171 -61
  15. package/src/providers/openai.ts +174 -44
  16. package/src/providers/openrouter.ts +129 -44
  17. package/src/providers/shared.ts +169 -17
  18. package/src/providers.ts +375 -11
  19. package/src/scheduler/scheduled-actions.md +41 -0
  20. package/src/scheduler/scheduler.ts +814 -0
  21. package/src/scheduler/store.ts +263 -0
  22. package/src/scheduler/time.ts +148 -0
  23. package/src/scheduler/tool.ts +151 -0
  24. package/src/session/store.ts +229 -113
  25. package/src/streaming/event-stream.ts +6 -4
  26. package/src/telemetry-file.ts +31 -0
  27. package/src/telemetry.ts +65 -0
  28. package/src/tools/executor.ts +163 -38
  29. package/src/tools/subagent-result.ts +60 -0
  30. package/src/types/agent.ts +74 -22
  31. package/src/types/model.ts +39 -0
  32. package/src/types/response.ts +9 -0
  33. package/src/types/scheduler.ts +81 -0
  34. package/src/types/tool.ts +15 -0
  35. package/src/utils/cache.ts +14 -5
  36. package/src/utils/documents.ts +112 -52
  37. package/src/utils/env.ts +21 -7
  38. package/src/utils/errors.ts +26 -1
  39. package/src/utils/headers.ts +15 -15
  40. package/src/utils/home.ts +30 -0
  41. package/src/utils/media.ts +57 -12
  42. package/src/utils/raw-capture.ts +76 -0
  43. package/src/utils/retry.ts +11 -3
  44. package/src/utils/safe-io.ts +252 -0
  45. package/src/utils/session.ts +7 -8
  46. package/src/data/README.md +0 -84
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Disk layer for scheduled actions. Everything lives under one home folder:
3
+ *
4
+ * <home>/scheduled_actions/tasks.json every action, one file
5
+ * <home>/scheduled_actions/deliveries.json saved results waiting for their delivery time
6
+ * <home>/scheduled_actions/<id>/<date>.md one file per run
7
+ *
8
+ * The files are the source of truth: every write re-reads first (never writes
9
+ * from a stale copy) and goes through `writeFileAtomic` (temp file + rename).
10
+ * Sync `fs`, like the session store.
11
+ */
12
+ import * as fs from "node:fs";
13
+ import * as path from "node:path";
14
+ import { z } from "zod";
15
+ import { writeFileAtomic } from "../session/store.ts";
16
+ import { telemetryBus } from "../telemetry.ts";
17
+ import { resolveHome } from "../utils/home.ts";
18
+ export { resolveHome };
19
+ import { ValidationError } from "../types/errors.ts";
20
+ import type { ScheduledActionRecord, ScheduledRunStatus } from "../types/scheduler.ts";
21
+ import { durationMs, runFileName } from "./time.ts";
22
+
23
+ /** Lowercase kebab-case. */
24
+ export const TASK_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
25
+ /** 48-char slug + `-` + 6 random chars. */
26
+ export const MAX_TASK_ID_LENGTH = 55;
27
+
28
+ /** A saved run waiting for its delivery time (one entry of `deliveries.json`). */
29
+ export interface PendingDelivery {
30
+ id: string;
31
+ /** Run file path, relative to `scheduled_actions/`. */
32
+ file: string;
33
+ status: ScheduledRunStatus;
34
+ readyAt: string;
35
+ deliverAt: string;
36
+ }
37
+
38
+ /** Header written at the top of every run file. */
39
+ export interface RunHeader {
40
+ id: string;
41
+ name: string;
42
+ status: ScheduledRunStatus;
43
+ startedAt: string;
44
+ finishedAt: string;
45
+ deliverAt: string;
46
+ /** The run finished after its delivery time. */
47
+ late: boolean;
48
+ model?: string;
49
+ tokens?: number;
50
+ /** `unknown` when the model has no known pricing (never a made-up 0). */
51
+ costUsd?: number | "unknown";
52
+ error?: string;
53
+ }
54
+
55
+ const baseDir = (home: string): string => path.join(home, "scheduled_actions");
56
+ const tasksFile = (home: string): string => path.join(baseDir(home), "tasks.json");
57
+ const deliveriesFile = (home: string): string => path.join(baseDir(home), "deliveries.json");
58
+
59
+ /** Absolute path of a run file given its `scheduled_actions/`-relative path. */
60
+ export function runPath(home: string, relativeFile: string): string {
61
+ return path.resolve(baseDir(home), relativeFile);
62
+ }
63
+
64
+ /** Renames the pre-0.5.0 `scheduled_tasks/` folder to `scheduled_actions/` (once, only when the new one does not exist). */
65
+ export function migrateLegacyFolder(home: string): void {
66
+ const legacy = path.join(home, "scheduled_tasks");
67
+ try {
68
+ if (fs.existsSync(legacy) && !fs.existsSync(baseDir(home))) fs.renameSync(legacy, baseDir(home));
69
+ } catch {
70
+ // why: best effort; the old folder simply stays where it is.
71
+ }
72
+ }
73
+
74
+ type Raw = Record<string, unknown>;
75
+
76
+ /** Reads a `{ version, <key>: [...] }` file. Missing = empty; corrupt = quarantined (`*.corrupt-<ts>`) + empty. */
77
+ function readList(file: string, key: "tasks" | "pending"): Raw[] {
78
+ let text: string;
79
+ try {
80
+ text = fs.readFileSync(file, "utf8");
81
+ } catch {
82
+ return [];
83
+ }
84
+ try {
85
+ const list = (JSON.parse(text) as Raw | null)?.[key];
86
+ if (!Array.isArray(list)) throw new Error(`"${key}" is not an array`);
87
+ return list.filter((x): x is Raw => !!x && typeof x === "object" && !Array.isArray(x));
88
+ } catch {
89
+ const moved = `${file}.corrupt-${Date.now()}`;
90
+ try {
91
+ fs.renameSync(file, moved);
92
+ } catch {
93
+ // why: quarantine is best-effort; the file is ignored either way.
94
+ }
95
+ telemetryBus.emitWarning(
96
+ "scheduled_action.store_corrupt",
97
+ `[Agent Accelerator] ${path.basename(file)} is not valid; moved to ${path.basename(moved)} and starting empty.`,
98
+ { file: moved }
99
+ );
100
+ return [];
101
+ }
102
+ }
103
+
104
+ function writeList(file: string, key: "tasks" | "pending", list: Raw[]): void {
105
+ writeFileAtomic(file, JSON.stringify({ version: 1, [key]: list }, null, 2) + "\n");
106
+ }
107
+
108
+ /** Every record as stored. Records may be invalid: check with {@link validateRecord} before acting on one. */
109
+ export function readTasks(home: string): ScheduledActionRecord[] {
110
+ return readList(tasksFile(home), "tasks") as unknown as ScheduledActionRecord[];
111
+ }
112
+
113
+ /** Re-reads `tasks.json`, applies `fn` (mutating the array/records in place) and writes it back. */
114
+ export function updateTasks<T>(home: string, fn: (tasks: ScheduledActionRecord[]) => T): T {
115
+ const tasks = readTasks(home);
116
+ const result = fn(tasks);
117
+ writeList(tasksFile(home), "tasks", tasks as unknown as Raw[]);
118
+ return result;
119
+ }
120
+
121
+ export function readDeliveries(home: string): PendingDelivery[] {
122
+ return readList(deliveriesFile(home), "pending") as unknown as PendingDelivery[];
123
+ }
124
+
125
+ /** Re-reads `deliveries.json`, applies `fn` (mutating in place) and writes it back. */
126
+ export function updateDeliveries<T>(home: string, fn: (pending: PendingDelivery[]) => T): T {
127
+ const pending = readDeliveries(home);
128
+ const result = fn(pending);
129
+ writeList(deliveriesFile(home), "pending", pending as unknown as Raw[]);
130
+ return result;
131
+ }
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // Record validation (the file is hand/agent-editable, so it is untrusted)
135
+ // ---------------------------------------------------------------------------
136
+
137
+ const iso = z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/);
138
+ const recordSchema = z
139
+ .object({
140
+ id: z.string().max(MAX_TASK_ID_LENGTH).regex(TASK_ID_PATTERN),
141
+ name: z.string().min(1),
142
+ agent: z.string().min(1),
143
+ instructions: z.string().min(1),
144
+ task: z.string().min(1),
145
+ tools: z.array(z.string()),
146
+ timeoutMs: z.number().optional(),
147
+ schedule: z.object({
148
+ timeToDeliver: z.string(),
149
+ meridiem: z.enum(["AM", "PM"]),
150
+ startCookingInAdvance: z.string(),
151
+ repeatEvery: z.string().optional(),
152
+ retries: z.number().int().min(0).optional(),
153
+ }),
154
+ state: z.enum(["waiting", "cooking", "missed", "done"]),
155
+ nextCookAt: iso.nullable(),
156
+ nextDeliverAt: iso.nullable(),
157
+ current: z.object({ cookStartedAt: iso, deliverAt: iso }).optional(),
158
+ })
159
+ .refine((r) => r.state !== "cooking" || r.current !== undefined, { message: "current: required while cooking" });
160
+
161
+ /** `undefined` when the record is usable, else a one-line reason. Never throws. */
162
+ export function validateRecord(raw: unknown): string | undefined {
163
+ const parsed = recordSchema.safeParse(raw);
164
+ if (!parsed.success) {
165
+ return parsed.error.issues.map((i) => `${i.path.join(".") || "record"}: ${i.message}`).join("; ");
166
+ }
167
+ const { startCookingInAdvance, repeatEvery } = parsed.data.schedule;
168
+ try {
169
+ durationMs(startCookingInAdvance, "schedule.startCookingInAdvance");
170
+ if (repeatEvery !== undefined && durationMs(repeatEvery, "schedule.repeatEvery") === 0) {
171
+ return "schedule.repeatEvery: must be greater than 00:00:00";
172
+ }
173
+ } catch (err) {
174
+ return err instanceof Error ? err.message : String(err);
175
+ }
176
+ return undefined;
177
+ }
178
+
179
+ const deliverySchema = z.object({
180
+ id: z.string().min(1),
181
+ file: z.string().min(1),
182
+ status: z.enum(["ok", "failed"]),
183
+ readyAt: iso,
184
+ deliverAt: iso,
185
+ });
186
+
187
+ /** `undefined` when a `deliveries.json` entry is usable, else a one-line reason. Never throws. */
188
+ export function validateDelivery(raw: unknown): string | undefined {
189
+ const parsed = deliverySchema.safeParse(raw);
190
+ return parsed.success ? undefined : parsed.error.issues.map((i) => `${i.path.join(".") || "entry"}: ${i.message}`).join("; ");
191
+ }
192
+
193
+ // ---------------------------------------------------------------------------
194
+ // Run files
195
+ // ---------------------------------------------------------------------------
196
+
197
+ function renderRun(header: RunHeader, body: string): string {
198
+ const lines = ["---"];
199
+ for (const [key, value] of Object.entries(header)) {
200
+ if (value !== undefined) lines.push(`${key}: ${String(value).replace(/\s+/g, " ")}`);
201
+ }
202
+ lines.push("---", "", body);
203
+ return lines.join("\n") + "\n";
204
+ }
205
+
206
+ /**
207
+ * Writes one run under its final name (`27-october-2026.md`, `-2`, `-3` on a
208
+ * same-day clash) and returns its path relative to `scheduled_actions/`.
209
+ */
210
+ export function writeRunFile(home: string, id: string, deliverAt: Date, header: RunHeader, body: string): string {
211
+ const dir = path.join(baseDir(home), id);
212
+ const base = runFileName(deliverAt);
213
+ let name = `${base}.md`;
214
+ for (let n = 2; fs.existsSync(path.join(dir, name)); n++) name = `${base}-${n}.md`;
215
+ writeFileAtomic(path.join(dir, name), renderRun(header, body));
216
+ return `${id}/${name}`;
217
+ }
218
+
219
+ /** Reads a run file (header fields as strings + result text). `undefined` when missing or outside the folder. */
220
+ export function readRun(home: string, relativeFile: string): { header: Record<string, string>; body: string } | undefined {
221
+ const file = runPath(home, relativeFile);
222
+ if (!file.startsWith(baseDir(home) + path.sep)) return undefined;
223
+ let text: string;
224
+ try {
225
+ text = fs.readFileSync(file, "utf8");
226
+ } catch {
227
+ return undefined;
228
+ }
229
+ const m = /^---\n([\s\S]*?)\n---\n\n?([\s\S]*)$/.exec(text);
230
+ if (!m) return undefined;
231
+ const header: Record<string, string> = {};
232
+ for (const line of m[1]!.split("\n")) {
233
+ const i = line.indexOf(": ");
234
+ if (i > 0) header[line.slice(0, i)] = line.slice(i + 2);
235
+ }
236
+ return { header, body: m[2]!.replace(/\n$/, "") };
237
+ }
238
+
239
+ /** The run file a crashed cook already wrote (matched by its `startedAt`), if any. */
240
+ export function findRunFile(home: string, id: string, deliverAt: Date, startedAt: string): string | undefined {
241
+ const base = runFileName(deliverAt);
242
+ let names: string[];
243
+ try {
244
+ names = fs.readdirSync(path.join(baseDir(home), id));
245
+ } catch {
246
+ return undefined;
247
+ }
248
+ for (const name of names) {
249
+ if (!name.startsWith(base) || !name.endsWith(".md")) continue;
250
+ if (readRun(home, `${id}/${name}`)?.header["startedAt"] === startedAt) return `${id}/${name}`;
251
+ }
252
+ return undefined;
253
+ }
254
+
255
+ /** Deletes an action's folder (`purge`). Only ever inside `scheduled_actions/`. */
256
+ export function removeTaskFolder(home: string, id: string): void {
257
+ const root = baseDir(home);
258
+ const dir = path.resolve(root, id);
259
+ if (!TASK_ID_PATTERN.test(id) || id.length > MAX_TASK_ID_LENGTH || !dir.startsWith(root + path.sep)) {
260
+ throw new ValidationError(`Refusing to remove ${JSON.stringify(id)}: not a scheduled action id.`);
261
+ }
262
+ fs.rmSync(dir, { recursive: true, force: true });
263
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Pure time maths for scheduled actions. Every function takes `now` (or a
3
+ * date) explicitly and uses only the machine's own local clock: no timezone,
4
+ * no DST logic, no `Intl`. `DD:HH:MM` strings are relative (days, hours,
5
+ * minutes) and are resolved to local `YYYY-MM-DDTHH:mm:ss` strings for storage.
6
+ */
7
+ import { ValidationError } from "../types/errors.ts";
8
+
9
+ const MONTHS = [
10
+ "january", "february", "march", "april", "may", "june",
11
+ "july", "august", "september", "october", "november", "december",
12
+ ];
13
+ const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
14
+
15
+ const DDHHMM = /^(\d{2,}):(\d{2}):(\d{2})$/;
16
+ const LOCAL_ISO = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})$/;
17
+
18
+ const pad2 = (n: number): string => String(n).padStart(2, "0");
19
+ const show = (v: unknown): string => JSON.stringify(v) ?? String(v);
20
+
21
+ /** Parses `DD:HH:MM` (DD >= 2 digits, MM < 60). HH is range-checked by the caller. */
22
+ export function parseDDHHMM(value: unknown, field: string, example = "00:07:00"): { dd: number; hh: number; mm: number } {
23
+ const m = typeof value === "string" ? DDHHMM.exec(value) : null;
24
+ if (!m || Number(m[3]) > 59) {
25
+ throw new ValidationError(
26
+ `${field}: expected "DD:HH:MM" (DD days with at least two digits, MM 00-59), e.g. "${example}"; got ${show(value)}.`
27
+ );
28
+ }
29
+ return { dd: Number(m[1]), hh: Number(m[2]), mm: Number(m[3]) };
30
+ }
31
+
32
+ /** Elapsed ms of a `DD:HH:MM` duration (HH 00-23): `startCookingInAdvance`, `repeatEvery`. */
33
+ export function durationMs(value: unknown, field: string): number {
34
+ const { dd, hh, mm } = parseDDHHMM(value, field, "00:00:10");
35
+ if (hh > 23) {
36
+ throw new ValidationError(
37
+ `${field}: as a duration HH must be 00-23 (e.g. "00:00:10" = 10 minutes, "01:00:00" = 1 day); got ${show(value)}.`
38
+ );
39
+ }
40
+ return ((dd * 24 + hh) * 60 + mm) * 60_000;
41
+ }
42
+
43
+ /** Local `Monday, 26 October, 03:55:31 PM, 2026` (no timezone name on purpose). */
44
+ export function formatDatetime(d: Date): string {
45
+ const h12 = d.getHours() % 12 || 12;
46
+ const ampm = d.getHours() < 12 ? "AM" : "PM";
47
+ const month = MONTHS[d.getMonth()]!;
48
+ return (
49
+ `${DAYS[d.getDay()]}, ${d.getDate()} ${month[0]!.toUpperCase()}${month.slice(1)}, ` +
50
+ `${pad2(h12)}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())} ${ampm}, ${d.getFullYear()}`
51
+ );
52
+ }
53
+
54
+ /**
55
+ * Resolves `timeToDeliver` (`DD` days from today, `HH:MM` on a 12-hour clock)
56
+ * to a local date. Throws when the result is not in the future.
57
+ */
58
+ export function resolveDelivery(now: Date, timeToDeliver: unknown, meridiem: unknown): Date {
59
+ // Report a bad time AND a bad meridiem together so one retry can fix both.
60
+ const issues: string[] = [];
61
+ let parts: { dd: number; hh: number; mm: number } | undefined;
62
+ try {
63
+ parts = parseDDHHMM(timeToDeliver, "scheduled_action.timeToDeliver");
64
+ if (parts.hh < 1 || parts.hh > 12) {
65
+ throw new ValidationError(
66
+ `scheduled_action.timeToDeliver: HH is a 12-hour clock hour 01-12 (the AM/PM goes in meridiem), e.g. "00:07:00" + "AM" = today 7:00 AM; got ${show(timeToDeliver)}.`
67
+ );
68
+ }
69
+ } catch (err) {
70
+ issues.push(err instanceof Error ? err.message : String(err));
71
+ }
72
+ if (meridiem !== "AM" && meridiem !== "PM") {
73
+ issues.push(`scheduled_action.meridiem: must be "AM" or "PM"; got ${show(meridiem)}.`);
74
+ }
75
+ if (!parts || issues.length > 0) throw new ValidationError(issues.join(" "));
76
+ const { dd, hh, mm } = parts;
77
+ const d = new Date(now);
78
+ d.setDate(d.getDate() + dd);
79
+ d.setHours((hh % 12) + (meridiem === "PM" ? 12 : 0), mm, 0, 0);
80
+ if (Number.isNaN(d.getTime())) {
81
+ throw new ValidationError(`scheduled_action.timeToDeliver: ${show(timeToDeliver)} is too far in the future.`);
82
+ }
83
+ if (d.getTime() <= now.getTime()) {
84
+ throw new ValidationError(
85
+ `scheduled_action.timeToDeliver: ${show(timeToDeliver)} ${meridiem} resolves to ${formatDatetime(d)}, which is already in the past ` +
86
+ `(now: ${formatDatetime(now)}). Use DD "01" for tomorrow, e.g. "01:${pad2(hh)}:${pad2(mm)}".`
87
+ );
88
+ }
89
+ return d;
90
+ }
91
+
92
+ /** Next delivery after `deliverAt`. Calendar maths (days, then hours/minutes): 7:00 AM stays 7:00 AM. */
93
+ export function nextOccurrence(deliverAt: Date, repeatEvery: string): Date {
94
+ const { dd, hh, mm } = parseDDHHMM(repeatEvery, "scheduled_action.repeatEvery", "01:00:00");
95
+ const d = new Date(deliverAt);
96
+ d.setDate(d.getDate() + dd);
97
+ d.setHours(d.getHours() + hh, d.getMinutes() + mm);
98
+ return d;
99
+ }
100
+
101
+ /** First occurrence in the series (starting at `from`) that is strictly after `now`. */
102
+ export function nextAfter(from: Date, repeatEvery: string, now: Date): Date {
103
+ let d = from;
104
+ while (d.getTime() <= now.getTime()) {
105
+ const next = nextOccurrence(d, repeatEvery);
106
+ if (next.getTime() <= d.getTime()) {
107
+ throw new ValidationError("scheduled_action.repeatEvery: must be greater than 00:00:00.");
108
+ }
109
+ d = next;
110
+ }
111
+ return d;
112
+ }
113
+
114
+ /** When a run must start so it can deliver at `deliverAt`. */
115
+ export function cookTime(deliverAt: Date, cookMs: number): Date {
116
+ return new Date(deliverAt.getTime() - cookMs);
117
+ }
118
+
119
+ /** A run still cooking when the next is due is skipped, so the lead time must be shorter than the gap. */
120
+ export function assertCookShorterThanRepeat(cookMs: number, repeatMs: number): void {
121
+ if (cookMs >= repeatMs) {
122
+ throw new ValidationError(
123
+ "scheduled_action.startCookingInAdvance: must be shorter than repeatEvery. A run still cooking when the next one is due is skipped, " +
124
+ "so a lead time as long as the gap loses runs. Shorten startCookingInAdvance, widen repeatEvery, or give the worker a lighter task."
125
+ );
126
+ }
127
+ }
128
+
129
+ /** Local `YYYY-MM-DDTHH:mm:ss`, the storage format (no offset). */
130
+ export function toLocalIso(d: Date): string {
131
+ return (
132
+ `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}` +
133
+ `T${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`
134
+ );
135
+ }
136
+
137
+ /** Inverse of {@link toLocalIso}; read as local time. */
138
+ export function parseLocalIso(value: string): Date {
139
+ const m = LOCAL_ISO.exec(value);
140
+ if (!m) throw new ValidationError(`Expected a local time like "2026-10-27T07:00:00"; got ${show(value)}.`);
141
+ const [y, mo, d, h, mi, s] = m.slice(1).map(Number) as [number, number, number, number, number, number];
142
+ return new Date(y, mo - 1, d, h, mi, s);
143
+ }
144
+
145
+ /** Run file base name for a delivery date, e.g. `27-october-2026`. */
146
+ export function runFileName(deliverAt: Date): string {
147
+ return `${pad2(deliverAt.getDate())}-${MONTHS[deliverAt.getMonth()]}-${deliverAt.getFullYear()}`;
148
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * The two model-facing tools of scheduled actions: `scheduled_actions` (one
3
+ * flat tool with a `command` field, a thin wrapper over the developer
4
+ * functions) and `get_datetime`. The tool manual lives in
5
+ * `scheduled-actions.md`, not in code.
6
+ */
7
+ import { readFileSync } from "node:fs";
8
+ import { z } from "zod";
9
+ import type { Agent } from "../agent/agent.ts";
10
+ import { tool } from "../tools/tool.ts";
11
+ import { ToolError } from "../types/errors.ts";
12
+ import type { ToolDefinition } from "../types/tool.ts";
13
+ import type { ScheduledActionInput, ScheduledActionPatch } from "../types/scheduler.ts";
14
+ import {
15
+ createScheduledAction,
16
+ getScheduler,
17
+ killScheduledAction,
18
+ listScheduledActions,
19
+ runScheduledAction,
20
+ skipScheduledAction,
21
+ updateScheduledAction,
22
+ validateInput,
23
+ } from "./scheduler.ts";
24
+ import { readRun } from "./store.ts";
25
+ import { formatDatetime } from "./time.ts";
26
+
27
+ const COMMANDS = ["create", "list", "get", "update", "kill", "run_now", "skip"] as const;
28
+
29
+ let manual: string | undefined;
30
+ /** Read on first use (not at import), so a program that never enables scheduling never touches the file. */
31
+ function loadManual(): string {
32
+ return (manual ??= readFileSync(new URL("./scheduled-actions.md", import.meta.url), "utf8"));
33
+ }
34
+
35
+ /** Returns the machine's local date and time. Exported for any agent; auto-registered with the scheduling tools. */
36
+ export const getDatetimeTool: ToolDefinition = tool({
37
+ name: "get_datetime",
38
+ description:
39
+ 'Returns the machine\'s current local date and time, e.g. "Monday, 26 October, 03:55:31 PM, 2026" (no timezone). Call it before scheduling anything.',
40
+ input: z.object({}),
41
+ repeatable: true,
42
+ execute: () => formatDatetime(new Date()),
43
+ });
44
+
45
+ const scheduledActionSchema = z.object({
46
+ name: z.string().optional().describe("Human label."),
47
+ id: z.string().optional().describe("Lowercase kebab-case slug, max 48 chars (create only)."),
48
+ timeToDeliver: z.string().optional().describe('"DD:HH:MM" - DD days from today, HH:MM on a 12-hour clock.'),
49
+ meridiem: z.enum(["AM", "PM"]).optional(),
50
+ startCookingInAdvance: z.string().optional().describe('Duration "DD:HH:MM" before delivery when the run starts.'),
51
+ repeatEvery: z.string().optional().describe('Duration "DD:HH:MM" between deliveries. Omit to run once.'),
52
+ retries: z.number().optional().describe("Extra attempts if a run fails."),
53
+ });
54
+
55
+ const describedId = "Full action id including its 6-character suffix (get, update, kill, run_now, skip).";
56
+
57
+ /** Builds the `scheduled_actions` tool for `agent`; its description is the manual with the worker pool filled in. */
58
+ export function createScheduledActionsTool(agent: Agent): ToolDefinition {
59
+ const home = agent.home;
60
+ const poolTools = agent.dynamicSubagents?.tools ?? {};
61
+ const poolNames = Object.keys(poolTools);
62
+ const example = (): string =>
63
+ JSON.stringify({
64
+ command: "create",
65
+ instructions: "You write a short, sourced AI research brief.",
66
+ task: "Summarize the most important AI news of the last day in 5 bullets.",
67
+ ...(poolNames[0] ? { tools: [poolNames[0]] } : {}),
68
+ scheduled_action: {
69
+ name: "Daily AI Research Brief",
70
+ id: "daily-ai-brief",
71
+ timeToDeliver: "01:07:00",
72
+ meridiem: "AM",
73
+ startCookingInAdvance: "00:00:30",
74
+ repeatEvery: "01:00:00",
75
+ },
76
+ });
77
+
78
+ return tool({
79
+ name: "scheduled_actions",
80
+ description: loadManual().replace("{{TOOLS}}", poolNames.length > 0 ? poolNames.join(", ") : "none (omit tools)"),
81
+ input: z.object({
82
+ command: z.enum(COMMANDS),
83
+ id: z.string().optional().describe(describedId),
84
+ role: z.string().optional().describe("Short persona for the worker."),
85
+ instructions: z.string().optional().describe("The worker's system prompt (create, update)."),
86
+ task: z.string().optional().describe("The concrete assignment for every run (create, update)."),
87
+ tools: z.array(z.string()).optional().describe(`Worker tools to grant, from: ${poolNames.join(", ") || "none"}.`),
88
+ timeoutMs: z.number().optional().describe("Per-run time limit in ms (> 0)."),
89
+ scheduled_action: scheduledActionSchema.optional(),
90
+ force: z.boolean().optional().describe("kill: abort a run that is cooking. Ask the user first."),
91
+ purge: z.boolean().optional().describe("kill: also delete past run files."),
92
+ }),
93
+ execute: async (a) => {
94
+ const sa = a.scheduled_action ?? {};
95
+ const needId = (): string => {
96
+ const id = a.id ?? sa.id;
97
+ if (!id) {
98
+ throw new ToolError(`${a.command} needs "id" (the full id including its 6-character suffix; see list). Example: ${JSON.stringify({ command: a.command, id: "daily-ai-brief-d64aj2" })}`);
99
+ }
100
+ return id;
101
+ };
102
+
103
+ switch (a.command) {
104
+ case "create": {
105
+ const input = { role: a.role, instructions: a.instructions, task: a.task, tools: a.tools, timeoutMs: a.timeoutMs, scheduled_action: sa } as ScheduledActionInput;
106
+ const problems = validateInput(input, poolTools, getScheduler(home).now(), { requireLead: true });
107
+ if (problems.length > 0) {
108
+ throw new ToolError(
109
+ `create has ${problems.length} problem${problems.length === 1 ? "" : "s"}: ${problems.map((p, i) => `(${i + 1}) ${p}`).join(" ")} Valid example: ${example()}`
110
+ );
111
+ }
112
+ return createScheduledAction(agent, input);
113
+ }
114
+ case "list":
115
+ return listScheduledActions({ home });
116
+ case "get": {
117
+ const id = needId();
118
+ const rec = listScheduledActions({ home }).find((t) => t.id === id);
119
+ if (!rec) throw new ToolError(`No scheduled action with id "${id}". Use list to see the ids.`);
120
+ const run = rec.lastRun ? readRun(home, rec.lastRun.file) : undefined;
121
+ return { ...rec, ...(run ? { latestResult: run.body } : {}) };
122
+ }
123
+ case "update": {
124
+ const id = needId();
125
+ const { id: _ignored, ...schedule } = sa;
126
+ const patch: ScheduledActionPatch = {
127
+ role: a.role,
128
+ instructions: a.instructions,
129
+ task: a.task,
130
+ tools: a.tools,
131
+ timeoutMs: a.timeoutMs,
132
+ scheduled_action: schedule,
133
+ };
134
+ const changes = [a.role, a.instructions, a.task, a.tools, a.timeoutMs, ...Object.values(schedule)];
135
+ if (changes.every((v) => v === undefined)) {
136
+ throw new ToolError(`update needs at least one field to change. Example: ${JSON.stringify({ command: "update", id, task: "The new assignment." })}`);
137
+ }
138
+ return updateScheduledAction(id, patch, { home });
139
+ }
140
+ case "kill":
141
+ return killScheduledAction(needId(), { home, force: a.force, purge: a.purge });
142
+ case "run_now":
143
+ return runScheduledAction(needId(), { home });
144
+ case "skip":
145
+ return skipScheduledAction(needId(), { home });
146
+ default:
147
+ throw new ToolError(`Unknown command ${JSON.stringify((a as { command?: unknown }).command)}. Use one of: ${COMMANDS.join(", ")}.`);
148
+ }
149
+ },
150
+ });
151
+ }