loopengine 0.1.68 → 0.1.70

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.
package/README.md CHANGED
@@ -121,6 +121,7 @@ code changes or redeploy needed for most of it:
121
121
  | **Tools** | Your hand-written tools, tools from integrations (e.g. [Composio](https://composio.dev)) and subagents, with whether each call runs on its own, asks first, or is denied. Build an HTTP tool or connect an integration without touching a file. |
122
122
  | **Skills** | Create, edit, and delete `SKILL.md` files for this agent directly in the browser — write the body, preview the rendered markdown, save. |
123
123
  | **Permissions** | Add, edit, and delete permission rules (actauth) — who, tool, decision — and choose what happens when no rule matches. |
124
+ | **Schedules** | Messages the agent receives on a timer — once at a set time, or every N minutes/hours/days. Run now, pause, resume, cancel, see each run's result, and optionally send results through the agent's notifier. |
124
125
  | **Environment** | Every setting an installed ability reads, with its project-wide value and this agent's own value side by side, and which one the agent actually uses. A secret is never shown again once set. |
125
126
 
126
127
  Models and Integrations in the sidebar show which model providers have a
@@ -182,9 +183,14 @@ waiting with Approve/Deny buttons. The tab warns you when an agent has
182
183
  Every notification links to a **decision page**: a signed, single-use
183
184
  link (`/decide/<token>`, valid 7 days) where whoever got the message can
184
185
  approve, deny, edit the arguments or answer a question, with no login and
185
- no app of your own. So the quickest setup is Slack: paste a bot token,
186
- pick a channel, save; each approval arrives with an "Open to approve or
187
- deny" link.
186
+ no app of your own. So the quickest setup is Slack: create the Slack app
187
+ from the manifest the console shows, paste its bot token, pick a channel
188
+ from the list, save; each approval arrives with an "Open to approve or
189
+ deny" link. Add the app's signing secret too and the message also gets
190
+ Approve/Deny buttons, handled by LoopEngine itself at
191
+ `/hooks/slack/<agent>`, with no handler to deploy. Lark cards carry the
192
+ same link. Links and buttons need the server to be reachable from where
193
+ people click (a public HTTPS address or a tunnel).
188
194
 
189
195
  For a third-party app calling an agent over plain HTTP:
190
196
 
@@ -206,6 +212,30 @@ click, a webhook HMAC signature, a magic-link token) ship under
206
212
  live and durable, worked examples, and how resumption actually works
207
213
  under the hood.
208
214
 
215
+ ## Scheduled tasks
216
+
217
+ The server runs one scheduler (`core/scheduler.ts`) that sends a message
218
+ to an agent later — once (`run_at`) or on an interval (`every: "1h"`) —
219
+ from the console's **Schedules** tab, the `GET/POST
220
+ /agents/<name>/schedules` API, or the agent itself: every agent has
221
+ built-in `system_schedule_task`, `system_list_scheduled_tasks`,
222
+ `system_check_scheduled_task` and `system_cancel_scheduled_task` tools,
223
+ so "remind me tomorrow at 9" just works. An
224
+ agent can only schedule, see or cancel its own tasks, up to 25 active at
225
+ a time (`LOOPENGINE_SCHEDULER_MAX_PER_AGENT`); `selfScheduling: false` in
226
+ its config removes the tools. Tasks left by the old `lp-task-scheduler`
227
+ ability are imported at start. Each run goes through the same
228
+ path as an HTTP call: an approval goes wherever the Permissions tab sends
229
+ it (and resumes the run once decided); with nowhere set, a call that
230
+ needs approval is denied. Intervals keep their original slot (an hourly
231
+ task created at 10:07 stays on :07); a run missed while the server was
232
+ down fires once when it's back. Tasks live in `.scheduler/` (or
233
+ `LOOPENGINE_SCHEDULER_DIR`); the tick is `LOOPENGINE_SCHEDULER_TICK_MS`
234
+ (10 s); `LOOPENGINE_SCHEDULER=off` turns the loop off for this process.
235
+ Behind a hosting platform, `GET /internal/activity` reports `nextWakeAt`
236
+ and `POST /internal/scheduler/tick` fires whatever is due, so a stopped
237
+ server can be woken in time.
238
+
209
239
  ## Ability system
210
240
 
211
241
  Giving an agent a new capability is usually three separate, hand-authored
@@ -203,6 +203,8 @@ export type HttpNotifierConfig = {
203
203
  appId: string;
204
204
  appSecret: string;
205
205
  chatId: string;
206
+ publicUrl?: string;
207
+ buttons?: boolean;
206
208
  };
207
209
  events: HttpNotifierEvent[];
208
210
  } | {
@@ -397,6 +399,12 @@ export interface AgentConfig {
397
399
  * for `cli`/`http_stream`: both always get the library's own live
398
400
  * defaults, with nothing to configure. */
399
401
  httpNotifier?: HttpNotifierConfig;
402
+ /** Whether this agent gets the built-in scheduling tools
403
+ * (system_schedule_task and friends, core/system-tools/schedule.ts),
404
+ * which let it schedule messages to itself — reminders, recurring
405
+ * checks — and list or cancel its own scheduled tasks. Default true;
406
+ * false leaves them out of this agent's tools entirely. */
407
+ selfScheduling?: boolean;
400
408
  /** Fires once, fire-and-forget, at the start of every `runAgent()`/
401
409
  * `resumeAgent()` call on the `http` channel (or no channel at all —
402
410
  * see below) — the "someone triggered this agent" signal, for
@@ -114,8 +114,8 @@ function buildResolved(httpNotifier) {
114
114
  break;
115
115
  }
116
116
  case 'lark': {
117
- const { appId, appSecret, chatId } = httpNotifier.config;
118
- const lark = new LarkNotifier({ appId, appSecret, chatId });
117
+ const { appId, appSecret, chatId, publicUrl, buttons } = httpNotifier.config;
118
+ const lark = new LarkNotifier({ appId, appSecret, chatId, publicUrl, buttons });
119
119
  resolved = {
120
120
  approver: events.has('approval') ? lark : undefined,
121
121
  questionHandler: events.has('question') ? lark : undefined,
@@ -9,11 +9,20 @@ export declare class LarkNotifier implements DurableApprover, DurableQuestionHan
9
9
  private readonly appId;
10
10
  private readonly appSecret;
11
11
  private readonly chatId;
12
+ private readonly publicUrl?;
13
+ private readonly buttons;
14
+ /** `publicUrl` / `buttons`: same meaning as SlackNotifier's — a decision
15
+ * link in each card when the server's address is known, and whether to
16
+ * add card buttons, which only work with a card-callback handler
17
+ * deployed (examples/notifier-handler/lark.ts). */
12
18
  constructor(options: {
13
19
  appId: string;
14
20
  appSecret: string;
15
21
  chatId: string;
22
+ publicUrl?: string;
23
+ buttons?: boolean;
16
24
  });
25
+ private decideLine;
17
26
  requestDurableApproval(tool: string, args: Record<string, unknown>, scope: Scope, reason: string): {
18
27
  pendingId: string;
19
28
  };
@@ -38,6 +38,8 @@
38
38
  // sending and receiving ever run in different processes), deliberately
39
39
  // not the approach used here.
40
40
  import { randomUUID } from 'node:crypto';
41
+ import { decidePath } from '../approval-links.js';
42
+ import { publicBaseUrl } from './webhook.js';
41
43
  /** Fixed id for the question form card's own input field — used both
42
44
  * when *building* the card (notifyPendingQuestion below) and when
43
45
  * *reading* the human's answer back out of the callback payload (the
@@ -62,10 +64,22 @@ export class LarkNotifier {
62
64
  appId;
63
65
  appSecret;
64
66
  chatId;
67
+ publicUrl;
68
+ buttons;
69
+ /** `publicUrl` / `buttons`: same meaning as SlackNotifier's — a decision
70
+ * link in each card when the server's address is known, and whether to
71
+ * add card buttons, which only work with a card-callback handler
72
+ * deployed (examples/notifier-handler/lark.ts). */
65
73
  constructor(options) {
66
74
  this.appId = options.appId;
67
75
  this.appSecret = options.appSecret;
68
76
  this.chatId = options.chatId;
77
+ this.publicUrl = options.publicUrl;
78
+ this.buttons = options.buttons ?? true;
79
+ }
80
+ decideLine(pendingId, kind) {
81
+ const base = publicBaseUrl(this.publicUrl);
82
+ return base ? `\n[${kind === 'approval' ? 'Open to approve or deny' : 'Open to answer'}](${base}${decidePath(pendingId, kind)})` : '';
69
83
  }
70
84
  requestDurableApproval(tool, args, scope, reason) {
71
85
  const pendingId = randomUUID();
@@ -80,16 +94,18 @@ export class LarkNotifier {
80
94
  tag: 'div',
81
95
  text: {
82
96
  tag: 'lark_md',
83
- content: `**tool:** ${tool}\n**args:** \`${JSON.stringify(args)}\`\n**scope:** ${scope.tenant}/${scope.environment}/${scope.agent}\n**reason:** ${reason}`,
97
+ content: `**tool:** ${tool}\n**args:** \`${JSON.stringify(args)}\`\n**scope:** ${scope.tenant}/${scope.environment}/${scope.agent}\n**reason:** ${reason}${this.decideLine(pendingId, 'approval')}`,
84
98
  },
85
99
  },
86
- {
87
- tag: 'action',
88
- actions: [
89
- { tag: 'button', text: { tag: 'plain_text', content: 'Approve' }, type: 'primary', value: { pendingId, decision: 'approve' } },
90
- { tag: 'button', text: { tag: 'plain_text', content: 'Deny' }, type: 'danger', value: { pendingId, decision: 'deny' } },
91
- ],
92
- },
100
+ ...(this.buttons
101
+ ? [{
102
+ tag: 'action',
103
+ actions: [
104
+ { tag: 'button', text: { tag: 'plain_text', content: 'Approve' }, type: 'primary', value: { pendingId, decision: 'approve' } },
105
+ { tag: 'button', text: { tag: 'plain_text', content: 'Deny' }, type: 'danger', value: { pendingId, decision: 'deny' } },
106
+ ],
107
+ }]
108
+ : []),
93
109
  ],
94
110
  };
95
111
  await this.sendCard(token, card, pendingId);
@@ -114,19 +130,23 @@ export class LarkNotifier {
114
130
  config: { wide_screen_mode: true },
115
131
  header: { title: { tag: 'plain_text', content: `Question from ${agent}` } },
116
132
  elements: [
117
- { tag: 'div', text: { tag: 'lark_md', content: question + (sessionId ? `\n_(session ${sessionId})_` : '') } },
118
- ...(optionButtons.length ? [{ tag: 'action', actions: optionButtons }] : []),
119
- {
120
- tag: 'form',
121
- name: 'answer_form',
122
- elements: [
123
- { tag: 'input', name: LARK_ANSWER_FIELD_TAG, placeholder: { tag: 'plain_text', content: 'Or type your own answer…' } },
133
+ { tag: 'div', text: { tag: 'lark_md', content: question + (sessionId ? `\n_(session ${sessionId})_` : '') + this.decideLine(pendingId, 'question') } },
134
+ ...(this.buttons
135
+ ? [
136
+ ...(optionButtons.length ? [{ tag: 'action', actions: optionButtons }] : []),
124
137
  {
125
- tag: 'action',
126
- actions: [{ tag: 'button', text: { tag: 'plain_text', content: 'Send' }, type: 'primary', action_type: 'form_submit', value: { pendingId } }],
138
+ tag: 'form',
139
+ name: 'answer_form',
140
+ elements: [
141
+ { tag: 'input', name: LARK_ANSWER_FIELD_TAG, placeholder: { tag: 'plain_text', content: 'Or type your own answer…' } },
142
+ {
143
+ tag: 'action',
144
+ actions: [{ tag: 'button', text: { tag: 'plain_text', content: 'Send' }, type: 'primary', action_type: 'form_submit', value: { pendingId } }],
145
+ },
146
+ ],
127
147
  },
128
- ],
129
- },
148
+ ]
149
+ : []),
130
150
  ],
131
151
  };
132
152
  await this.sendCard(token, card, pendingId);
@@ -17,6 +17,10 @@ export interface NotifierFile {
17
17
  }
18
18
  /** The env var each channel's secret is read from, per agent. */
19
19
  export declare const NOTIFIER_SECRET_VARS: Record<NotifierFileChannel, string>;
20
+ /** Optional: a Slack app's signing secret, per agent. Set, it turns on
21
+ * Approve/Deny buttons in Slack, handled by the server itself at
22
+ * POST /hooks/slack/<agent>; unset, messages carry only the decision link. */
23
+ export declare const NOTIFIER_SLACK_SIGNING_SECRET_VAR = "LOOPENGINE_NOTIFIER_SLACK_SIGNING_SECRET";
20
24
  export declare class NotifierFileError extends Error {
21
25
  }
22
26
  export declare function notifierFilePath(dir: string): string;
@@ -17,6 +17,10 @@ export const NOTIFIER_SECRET_VARS = {
17
17
  slack: 'LOOPENGINE_NOTIFIER_SLACK_BOT_TOKEN',
18
18
  lark: 'LOOPENGINE_NOTIFIER_LARK_APP_SECRET',
19
19
  };
20
+ /** Optional: a Slack app's signing secret, per agent. Set, it turns on
21
+ * Approve/Deny buttons in Slack, handled by the server itself at
22
+ * POST /hooks/slack/<agent>; unset, messages carry only the decision link. */
23
+ export const NOTIFIER_SLACK_SIGNING_SECRET_VAR = 'LOOPENGINE_NOTIFIER_SLACK_SIGNING_SECRET';
20
24
  const CHANNELS = ['webhook', 'slack', 'lark'];
21
25
  const EVENTS = ['approval', 'question', 'run_start', 'run_finish'];
22
26
  export class NotifierFileError extends Error {
@@ -95,10 +99,19 @@ export function notifierConfigFromFile(file, env) {
95
99
  case 'webhook':
96
100
  return { config: { channel: 'webhook', config: { webhookUrl: file.url, webhookSecret: secret, publicUrl: file.publicUrl }, events: file.events } };
97
101
  case 'slack':
98
- // No interactive buttons: a console-configured Slack notifier has
99
- // no interactivity endpoint behind it; the decision link replaces them.
100
- return { config: { channel: 'slack', config: { botToken: secret, channelId: file.channelId, publicUrl: file.publicUrl, buttons: false }, events: file.events } };
102
+ // Buttons only once the agent has a signing secret, i.e. the Slack
103
+ // app's interactivity points at /hooks/slack/<agent>; otherwise the
104
+ // decision link does the job.
105
+ return {
106
+ config: {
107
+ channel: 'slack',
108
+ config: { botToken: secret, channelId: file.channelId, publicUrl: file.publicUrl, buttons: Boolean(env.get(NOTIFIER_SLACK_SIGNING_SECRET_VAR)) },
109
+ events: file.events,
110
+ },
111
+ };
101
112
  case 'lark':
102
- return { config: { channel: 'lark', config: { appId: file.appId, appSecret: secret, chatId: file.chatId }, events: file.events } };
113
+ // No card buttons: nothing built in handles Lark's card callbacks yet,
114
+ // so a console-configured Lark notifier relies on the decision link.
115
+ return { config: { channel: 'lark', config: { appId: file.appId, appSecret: secret, chatId: file.chatId, publicUrl: file.publicUrl, buttons: false }, events: file.events } };
103
116
  }
104
117
  }
@@ -19,7 +19,7 @@ import { recordUsage } from './usage.js';
19
19
  import { loadAgentModule } from './discover-agents.js';
20
20
  import { agentAsTool } from './agent-as-tool.js';
21
21
  import { loadGatewayToolsFromDir } from './gateway-tools.js';
22
- import { systemTools, createAskUserTool, CliQuestionHandler, isDurableQuestionHandler } from './system-tools/index.js';
22
+ import { systemTools, createAskUserTool, createScheduleTools, CliQuestionHandler, isDurableQuestionHandler } from './system-tools/index.js';
23
23
  import { resolveHttpNotifier } from './http-notifier.js';
24
24
  import { CRASH_RECOVERY_CONTINUATION } from './session-store.js';
25
25
  export { systemTools } from './system-tools/index.js';
@@ -296,9 +296,14 @@ async function buildTurnContext(config, modelCall, options) {
296
296
  // (options.onEvent's own fallback) — see RunAgentOptions.onQuestionPending's
297
297
  // own doc comment for why those two are deliberately not the same thing.
298
298
  const askUserTool = createAskUserTool({ agent: config.name, sessionId: options.sessionId }, options.onQuestionPending);
299
+ // Bound to this agent: it can schedule, list and cancel only its own
300
+ // tasks (see core/system-tools/schedule.ts). AgentConfig.selfScheduling
301
+ // false leaves them out.
302
+ const scheduleTools = config.selfScheduling === false ? [] : createScheduleTools(config.name);
299
303
  const tools = dedupeToolsByName([
300
304
  ...systemTools,
301
305
  askUserTool,
306
+ ...scheduleTools,
302
307
  ...(config.tools ?? (await loadDefaultTools(config))),
303
308
  ...(await loadSubagentAsTools(config)),
304
309
  ...(await loadGatewayToolsFromDir(join(process.cwd(), 'agents', config.name))),
@@ -390,7 +395,7 @@ async function buildTurnContext(config, modelCall, options) {
390
395
  // ('ask' or 'deny', see loadRules' own doc comment) would otherwise
391
396
  // catch it, since neither system tool ever appears in an agent's own
392
397
  // actauth.yml.
393
- const systemToolInstances = new Set([...systemTools, askUserTool]);
398
+ const systemToolInstances = new Set([...systemTools, askUserTool, ...scheduleTools]);
394
399
  const toolSchemas = tools.map(({ name, description, input_schema }) => ({
395
400
  name,
396
401
  description,
@@ -0,0 +1,143 @@
1
+ export type Schedule = {
2
+ run_at: string;
3
+ } | {
4
+ every: string;
5
+ anchor?: string;
6
+ };
7
+ export type ScheduledTaskStatus = 'scheduled' | 'running' | 'awaiting_approval' | 'paused' | 'done' | 'cancelled';
8
+ export interface ScheduledTaskRun {
9
+ ran_at: string;
10
+ status: 'ok' | 'error';
11
+ result?: string;
12
+ error?: string;
13
+ session_id?: string;
14
+ }
15
+ export interface ScheduledTask {
16
+ task_id: string;
17
+ task_name?: string;
18
+ agent: string;
19
+ message: string;
20
+ schedule: Schedule;
21
+ status: ScheduledTaskStatus;
22
+ created_at: string;
23
+ next_run_at?: string;
24
+ run_count: number;
25
+ last_run_at?: string;
26
+ last_status?: 'ok' | 'error';
27
+ last_result?: string;
28
+ last_error?: string;
29
+ /** Set while a run is paused on an approval or question. */
30
+ pending?: {
31
+ ran_at: string;
32
+ session_id: string;
33
+ pending_ids: string[];
34
+ };
35
+ /** Send each run's result through the agent's notifier. */
36
+ notify?: boolean;
37
+ history: ScheduledTaskRun[];
38
+ /** Set by "run now" on a paused or finished task, so it goes back to
39
+ * that status after the extra run. */
40
+ run_now_from?: 'paused' | 'done';
41
+ /** Set by cancelling a run that's waiting on an approval: cancelled
42
+ * once that run finishes. */
43
+ cancel_after_run?: boolean;
44
+ }
45
+ export declare class ScheduleError extends Error {
46
+ }
47
+ export declare function parseEvery(every: string): number;
48
+ /** Human-readable schedule, for lists and messages. */
49
+ export declare function describeSchedule(schedule: Schedule): string;
50
+ export interface SchedulerStore {
51
+ list(): Promise<ScheduledTask[]>;
52
+ get(taskId: string): Promise<ScheduledTask | undefined>;
53
+ put(task: ScheduledTask): Promise<void>;
54
+ remove(taskId: string): Promise<void>;
55
+ /** Runs `fn` with exclusive access to one task (read, change, write). */
56
+ update(taskId: string, fn: (task: ScheduledTask | undefined) => ScheduledTask | undefined): Promise<ScheduledTask | undefined>;
57
+ }
58
+ /** One JSON file per task under `<dir>/tasks/` — one server process. */
59
+ export declare class FileSchedulerStore implements SchedulerStore {
60
+ private readonly dir;
61
+ constructor(dir: string);
62
+ private file;
63
+ list(): Promise<ScheduledTask[]>;
64
+ get(taskId: string): Promise<ScheduledTask | undefined>;
65
+ put(task: ScheduledTask): Promise<void>;
66
+ remove(taskId: string): Promise<void>;
67
+ update(taskId: string, fn: (task: ScheduledTask | undefined) => ScheduledTask | undefined): Promise<ScheduledTask | undefined>;
68
+ }
69
+ /** The process-wide store: `.scheduler/` in the project, or
70
+ * LOOPENGINE_SCHEDULER_DIR. */
71
+ export declare function getSchedulerStore(): SchedulerStore;
72
+ export declare function setSchedulerStore(next: SchedulerStore): void;
73
+ export interface CreateScheduledTaskInput {
74
+ agent: string;
75
+ message: string;
76
+ schedule: {
77
+ run_at?: unknown;
78
+ every?: unknown;
79
+ };
80
+ task_name?: string;
81
+ notify?: boolean;
82
+ }
83
+ export declare function createScheduledTask(input: CreateScheduledTaskInput, nowMs?: number): Promise<ScheduledTask>;
84
+ export declare function listScheduledTasks(filter?: {
85
+ agent?: string;
86
+ status?: ScheduledTaskStatus;
87
+ }): Promise<ScheduledTask[]>;
88
+ export declare function getScheduledTask(taskId: string): Promise<ScheduledTask | undefined>;
89
+ /** Stops future runs; history stays. A run already waiting on an
90
+ * approval isn't retracted. */
91
+ export declare function cancelScheduledTask(taskId: string): Promise<ScheduledTask>;
92
+ export declare function pauseScheduledTask(taskId: string): Promise<ScheduledTask>;
93
+ export declare function resumeScheduledTask(taskId: string, nowMs?: number): Promise<ScheduledTask>;
94
+ /** Fires the task on the next tick, without changing its schedule. A
95
+ * finished one-off task runs once more. */
96
+ export declare function runScheduledTaskNow(taskId: string, nowMs?: number): Promise<ScheduledTask>;
97
+ export declare function deleteScheduledTask(taskId: string): Promise<void>;
98
+ /** When the next task is due — for a host that stops idle servers and
99
+ * needs to wake this one in time (GET /internal/activity). */
100
+ export declare function nextScheduledWakeAt(): Promise<string | undefined>;
101
+ /** Moves the old lp-task-scheduler's task files (`<dir>/.tasks/*.json`)
102
+ * into this store once, renaming each to `.json.migrated`. Returns how
103
+ * many it imported. */
104
+ export declare function importLegacyScheduledTasks(dir: string): Promise<number>;
105
+ /** How a task fires — supplied by the server. `done`: the agent replied;
106
+ * `pending`: the run is paused on approvals/questions (see settleFromResume). */
107
+ export type FireTask = (task: ScheduledTask, sessionId: string) => Promise<{
108
+ kind: 'done';
109
+ text: string;
110
+ } | {
111
+ kind: 'pending';
112
+ pendingIds: string[];
113
+ }>;
114
+ export declare class Scheduler {
115
+ private readonly fire;
116
+ private readonly tickMs;
117
+ private readonly onSettled?;
118
+ private readonly inFlight;
119
+ private timer?;
120
+ constructor(options: {
121
+ fire: FireTask;
122
+ tickMs?: number;
123
+ onSettled?: (task: ScheduledTask, run: ScheduledTaskRun) => void;
124
+ });
125
+ /** Imports tasks left by the old lp-task-scheduler ability, recovers
126
+ * runs a restart cut off, then ticks every `tickMs`. */
127
+ start(): Promise<void>;
128
+ stop(): void;
129
+ /** How many runs are executing right now (for idle-shutdown checks). */
130
+ get running(): number;
131
+ /** Fires every task that's due. Safe to call any time (a host's wake-up
132
+ * request calls it directly). */
133
+ tick(nowMs?: number): Promise<number>;
134
+ private run;
135
+ /** A paused run resumed after a decision (adapters/http.ts calls this
136
+ * from the resolve path): settle it, or record the next decision it's
137
+ * now waiting on. */
138
+ settleFromResume(sessionId: string, outcome: {
139
+ text: string;
140
+ pendingIds?: string[];
141
+ }): Promise<void>;
142
+ private settle;
143
+ }