loopengine 0.1.69 → 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 +25 -0
- package/dist/core/agent-config.d.ts +6 -0
- package/dist/core/run-agent.js +7 -2
- package/dist/core/scheduler.d.ts +143 -0
- package/dist/core/scheduler.js +359 -0
- package/dist/core/system-tools/index.d.ts +1 -0
- package/dist/core/system-tools/index.js +1 -0
- package/dist/core/system-tools/schedule.d.ts +2 -0
- package/dist/core/system-tools/schedule.js +111 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/web/agents-config-page.js +155 -1
- package/dist/web/console-shell.js +1 -1
- package/dist/web/notifier-admin.d.ts +7 -1
- package/dist/web/notifier-admin.js +46 -1
- package/package.json +4 -2
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
|
|
@@ -211,6 +212,30 @@ click, a webhook HMAC signature, a magic-link token) ship under
|
|
|
211
212
|
live and durable, worked examples, and how resumption actually works
|
|
212
213
|
under the hood.
|
|
213
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
|
+
|
|
214
239
|
## Ability system
|
|
215
240
|
|
|
216
241
|
Giving an agent a new capability is usually three separate, hand-authored
|
|
@@ -399,6 +399,12 @@ export interface AgentConfig {
|
|
|
399
399
|
* for `cli`/`http_stream`: both always get the library's own live
|
|
400
400
|
* defaults, with nothing to configure. */
|
|
401
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;
|
|
402
408
|
/** Fires once, fire-and-forget, at the start of every `runAgent()`/
|
|
403
409
|
* `resumeAgent()` call on the `http` channel (or no channel at all —
|
|
404
410
|
* see below) — the "someone triggered this agent" signal, for
|
package/dist/core/run-agent.js
CHANGED
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
// Scheduled tasks: "send this message to that agent later — once, or
|
|
2
|
+
// every N minutes". One scheduler per server, started at boot by
|
|
3
|
+
// adapters/http.ts, which also supplies how a task actually fires (the
|
|
4
|
+
// same turn path as POST /agents/:name/messages, so approvals, notifiers
|
|
5
|
+
// and decision links behave exactly as they do for any HTTP call).
|
|
6
|
+
//
|
|
7
|
+
// Everything a tool or the console needs — create, list, pause, cancel —
|
|
8
|
+
// works on the store directly (below), so it works whether or not this
|
|
9
|
+
// process is the one running the loop; the loop picks changes up on its
|
|
10
|
+
// next tick.
|
|
11
|
+
//
|
|
12
|
+
// `every` is anchored: an hourly task created at 10:07 keeps firing at
|
|
13
|
+
// :07, however long each run takes. A run missed while the server was
|
|
14
|
+
// down fires once when it's back, then the schedule moves to the next
|
|
15
|
+
// future slot (no burst of catch-up runs).
|
|
16
|
+
import { randomUUID } from 'node:crypto';
|
|
17
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { KeyedMutex } from './session-store.js';
|
|
20
|
+
const HISTORY_LIMIT = 20;
|
|
21
|
+
const EVERY_PATTERN = /^(\d+)\s*(s|m|h|d)$/;
|
|
22
|
+
const EVERY_UNIT_MS = { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 };
|
|
23
|
+
/** Shortest allowed interval, so a typo can't hammer the model. */
|
|
24
|
+
const MIN_EVERY_MS = 30_000;
|
|
25
|
+
export class ScheduleError extends Error {
|
|
26
|
+
}
|
|
27
|
+
export function parseEvery(every) {
|
|
28
|
+
const match = EVERY_PATTERN.exec(every.trim());
|
|
29
|
+
if (!match)
|
|
30
|
+
throw new ScheduleError(`"${every}" is not a valid interval — use a number followed by s/m/h/d, e.g. "30m", "1h", "1d"`);
|
|
31
|
+
const ms = Number(match[1]) * EVERY_UNIT_MS[match[2]];
|
|
32
|
+
if (ms < MIN_EVERY_MS)
|
|
33
|
+
throw new ScheduleError(`"${every}" is too short — the shortest interval is 30s`);
|
|
34
|
+
return ms;
|
|
35
|
+
}
|
|
36
|
+
/** The first anchored slot strictly after `nowMs`. */
|
|
37
|
+
function nextAnchoredSlot(anchorMs, intervalMs, nowMs) {
|
|
38
|
+
if (nowMs < anchorMs)
|
|
39
|
+
return anchorMs;
|
|
40
|
+
return anchorMs + (Math.floor((nowMs - anchorMs) / intervalMs) + 1) * intervalMs;
|
|
41
|
+
}
|
|
42
|
+
/** Human-readable schedule, for lists and messages. */
|
|
43
|
+
export function describeSchedule(schedule) {
|
|
44
|
+
return 'run_at' in schedule ? `once at ${schedule.run_at}` : `every ${schedule.every}`;
|
|
45
|
+
}
|
|
46
|
+
const fileMutex = new KeyedMutex();
|
|
47
|
+
/** One JSON file per task under `<dir>/tasks/` — one server process. */
|
|
48
|
+
export class FileSchedulerStore {
|
|
49
|
+
dir;
|
|
50
|
+
constructor(dir) {
|
|
51
|
+
this.dir = path.join(dir, 'tasks');
|
|
52
|
+
}
|
|
53
|
+
file(taskId) {
|
|
54
|
+
if (!/^[A-Za-z0-9_-]+$/.test(taskId))
|
|
55
|
+
throw new ScheduleError(`invalid task id "${taskId}"`);
|
|
56
|
+
return path.join(this.dir, `${taskId}.json`);
|
|
57
|
+
}
|
|
58
|
+
async list() {
|
|
59
|
+
if (!existsSync(this.dir))
|
|
60
|
+
return [];
|
|
61
|
+
const tasks = [];
|
|
62
|
+
for (const name of readdirSync(this.dir)) {
|
|
63
|
+
if (!name.endsWith('.json'))
|
|
64
|
+
continue;
|
|
65
|
+
try {
|
|
66
|
+
tasks.push(JSON.parse(readFileSync(path.join(this.dir, name), 'utf8')));
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// mid-write or removed between readdir and read
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return tasks.sort((a, b) => a.created_at.localeCompare(b.created_at));
|
|
73
|
+
}
|
|
74
|
+
async get(taskId) {
|
|
75
|
+
const file = this.file(taskId);
|
|
76
|
+
return existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : undefined;
|
|
77
|
+
}
|
|
78
|
+
async put(task) {
|
|
79
|
+
mkdirSync(this.dir, { recursive: true });
|
|
80
|
+
const file = this.file(task.task_id);
|
|
81
|
+
writeFileSync(file + '.tmp', JSON.stringify(task, null, 2));
|
|
82
|
+
renameSync(file + '.tmp', file);
|
|
83
|
+
}
|
|
84
|
+
async remove(taskId) {
|
|
85
|
+
rmSync(this.file(taskId), { force: true });
|
|
86
|
+
}
|
|
87
|
+
async update(taskId, fn) {
|
|
88
|
+
return fileMutex.run(taskId, async () => {
|
|
89
|
+
const next = fn(await this.get(taskId));
|
|
90
|
+
if (next)
|
|
91
|
+
await this.put(next);
|
|
92
|
+
return next;
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
let store;
|
|
97
|
+
/** The process-wide store: `.scheduler/` in the project, or
|
|
98
|
+
* LOOPENGINE_SCHEDULER_DIR. */
|
|
99
|
+
export function getSchedulerStore() {
|
|
100
|
+
store ??= new FileSchedulerStore(process.env.LOOPENGINE_SCHEDULER_DIR || path.join(process.cwd(), '.scheduler'));
|
|
101
|
+
return store;
|
|
102
|
+
}
|
|
103
|
+
export function setSchedulerStore(next) {
|
|
104
|
+
store = next;
|
|
105
|
+
}
|
|
106
|
+
export async function createScheduledTask(input, nowMs = Date.now()) {
|
|
107
|
+
if (!input.agent)
|
|
108
|
+
throw new ScheduleError('agent is required');
|
|
109
|
+
if (!input.message?.trim())
|
|
110
|
+
throw new ScheduleError('message is required');
|
|
111
|
+
let schedule;
|
|
112
|
+
let nextRunMs;
|
|
113
|
+
if (typeof input.schedule?.run_at === 'string' && input.schedule.run_at) {
|
|
114
|
+
const runAtMs = Date.parse(input.schedule.run_at);
|
|
115
|
+
if (Number.isNaN(runAtMs))
|
|
116
|
+
throw new ScheduleError(`"${input.schedule.run_at}" is not a valid ISO 8601 timestamp`);
|
|
117
|
+
schedule = { run_at: new Date(runAtMs).toISOString() };
|
|
118
|
+
nextRunMs = runAtMs;
|
|
119
|
+
}
|
|
120
|
+
else if (typeof input.schedule?.every === 'string' && input.schedule.every) {
|
|
121
|
+
const intervalMs = parseEvery(input.schedule.every);
|
|
122
|
+
nextRunMs = nowMs + intervalMs;
|
|
123
|
+
schedule = { every: input.schedule.every.trim(), anchor: new Date(nextRunMs).toISOString() };
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
throw new ScheduleError('schedule must be { "run_at": "<ISO timestamp>" } or { "every": "<interval, e.g. 1h>" }');
|
|
127
|
+
}
|
|
128
|
+
const task = {
|
|
129
|
+
task_id: randomUUID(),
|
|
130
|
+
task_name: input.task_name?.trim() || undefined,
|
|
131
|
+
agent: input.agent,
|
|
132
|
+
message: input.message,
|
|
133
|
+
schedule,
|
|
134
|
+
status: 'scheduled',
|
|
135
|
+
created_at: new Date(nowMs).toISOString(),
|
|
136
|
+
next_run_at: new Date(nextRunMs).toISOString(),
|
|
137
|
+
run_count: 0,
|
|
138
|
+
notify: input.notify || undefined,
|
|
139
|
+
history: [],
|
|
140
|
+
};
|
|
141
|
+
await getSchedulerStore().put(task);
|
|
142
|
+
return task;
|
|
143
|
+
}
|
|
144
|
+
export async function listScheduledTasks(filter = {}) {
|
|
145
|
+
return (await getSchedulerStore().list()).filter((t) => (!filter.agent || t.agent === filter.agent) && (!filter.status || t.status === filter.status));
|
|
146
|
+
}
|
|
147
|
+
export async function getScheduledTask(taskId) {
|
|
148
|
+
return getSchedulerStore().get(taskId);
|
|
149
|
+
}
|
|
150
|
+
function mustExist(task, taskId) {
|
|
151
|
+
if (!task)
|
|
152
|
+
throw new ScheduleError(`no scheduled task "${taskId}"`);
|
|
153
|
+
return task;
|
|
154
|
+
}
|
|
155
|
+
/** Stops future runs; history stays. A run already waiting on an
|
|
156
|
+
* approval isn't retracted. */
|
|
157
|
+
export async function cancelScheduledTask(taskId) {
|
|
158
|
+
return mustExist(await getSchedulerStore().update(taskId, (t) => !t ? t : t.status === 'awaiting_approval' ? { ...t, cancel_after_run: true } : { ...t, status: 'cancelled', next_run_at: undefined }), taskId);
|
|
159
|
+
}
|
|
160
|
+
export async function pauseScheduledTask(taskId) {
|
|
161
|
+
return mustExist(await getSchedulerStore().update(taskId, (t) => (t && t.status === 'scheduled' ? { ...t, status: 'paused', next_run_at: undefined } : t)), taskId);
|
|
162
|
+
}
|
|
163
|
+
export async function resumeScheduledTask(taskId, nowMs = Date.now()) {
|
|
164
|
+
return mustExist(await getSchedulerStore().update(taskId, (t) => {
|
|
165
|
+
if (!t || t.status !== 'paused')
|
|
166
|
+
return t;
|
|
167
|
+
return { ...t, status: 'scheduled', next_run_at: new Date(nextRunAfter(t, nowMs) ?? nowMs).toISOString() };
|
|
168
|
+
}), taskId);
|
|
169
|
+
}
|
|
170
|
+
/** Fires the task on the next tick, without changing its schedule. A
|
|
171
|
+
* finished one-off task runs once more. */
|
|
172
|
+
export async function runScheduledTaskNow(taskId, nowMs = Date.now()) {
|
|
173
|
+
return mustExist(await getSchedulerStore().update(taskId, (t) => {
|
|
174
|
+
if (!t || t.status === 'running' || t.status === 'awaiting_approval' || t.status === 'cancelled')
|
|
175
|
+
return t;
|
|
176
|
+
return { ...t, status: 'scheduled', next_run_at: new Date(nowMs).toISOString(), run_now_from: t.status === 'paused' || t.status === 'done' ? t.status : undefined };
|
|
177
|
+
}), taskId);
|
|
178
|
+
}
|
|
179
|
+
export async function deleteScheduledTask(taskId) {
|
|
180
|
+
await getSchedulerStore().remove(taskId);
|
|
181
|
+
}
|
|
182
|
+
/** When the next task is due — for a host that stops idle servers and
|
|
183
|
+
* needs to wake this one in time (GET /internal/activity). */
|
|
184
|
+
export async function nextScheduledWakeAt() {
|
|
185
|
+
const due = (await getSchedulerStore().list()).filter((t) => t.status === 'scheduled' && t.next_run_at).map((t) => t.next_run_at);
|
|
186
|
+
return due.sort()[0];
|
|
187
|
+
}
|
|
188
|
+
function nextRunAfter(task, nowMs) {
|
|
189
|
+
if ('run_at' in task.schedule)
|
|
190
|
+
return undefined;
|
|
191
|
+
const intervalMs = parseEvery(task.schedule.every);
|
|
192
|
+
const anchorMs = task.schedule.anchor ? Date.parse(task.schedule.anchor) : Date.parse(task.created_at) + intervalMs;
|
|
193
|
+
return nextAnchoredSlot(anchorMs, intervalMs, nowMs);
|
|
194
|
+
}
|
|
195
|
+
/** Moves the old lp-task-scheduler's task files (`<dir>/.tasks/*.json`)
|
|
196
|
+
* into this store once, renaming each to `.json.migrated`. Returns how
|
|
197
|
+
* many it imported. */
|
|
198
|
+
export async function importLegacyScheduledTasks(dir) {
|
|
199
|
+
const legacyDir = path.join(dir, '.tasks');
|
|
200
|
+
if (!existsSync(legacyDir))
|
|
201
|
+
return 0;
|
|
202
|
+
let imported = 0;
|
|
203
|
+
for (const name of readdirSync(legacyDir)) {
|
|
204
|
+
if (!name.endsWith('.json'))
|
|
205
|
+
continue;
|
|
206
|
+
const file = path.join(legacyDir, name);
|
|
207
|
+
try {
|
|
208
|
+
const old = JSON.parse(readFileSync(file, 'utf8'));
|
|
209
|
+
if (!(await getSchedulerStore().get(old.task_id))) {
|
|
210
|
+
const task = { ...old, history: old.history ?? [], run_count: old.run_count ?? 0 };
|
|
211
|
+
if ('every' in task.schedule && !task.schedule.anchor && task.next_run_at)
|
|
212
|
+
task.schedule = { ...task.schedule, anchor: task.next_run_at };
|
|
213
|
+
await getSchedulerStore().put(task);
|
|
214
|
+
imported++;
|
|
215
|
+
}
|
|
216
|
+
renameSync(file, file + '.migrated');
|
|
217
|
+
}
|
|
218
|
+
catch (err) {
|
|
219
|
+
console.error(`[loopengine] could not import legacy scheduled task ${file}:`, err);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return imported;
|
|
223
|
+
}
|
|
224
|
+
/** lp-task-scheduler 1.x kept tasks under
|
|
225
|
+
* generated/scheduled-tasks/<agent>/.tasks/ (or SCHEDULER_STORE_DIR) and
|
|
226
|
+
* ran its own timer; scheduling is built in now, so the server imports
|
|
227
|
+
* those once at start. */
|
|
228
|
+
async function importLegacyAbilityTasks() {
|
|
229
|
+
const dirs = new Set();
|
|
230
|
+
const root = path.join(process.cwd(), 'generated', 'scheduled-tasks');
|
|
231
|
+
if (existsSync(root)) {
|
|
232
|
+
dirs.add(root);
|
|
233
|
+
for (const name of readdirSync(root))
|
|
234
|
+
if (name !== '.tasks')
|
|
235
|
+
dirs.add(path.join(root, name));
|
|
236
|
+
}
|
|
237
|
+
if (process.env.SCHEDULER_STORE_DIR)
|
|
238
|
+
dirs.add(path.resolve(process.env.SCHEDULER_STORE_DIR));
|
|
239
|
+
for (const dir of dirs) {
|
|
240
|
+
const n = await importLegacyScheduledTasks(dir);
|
|
241
|
+
if (n)
|
|
242
|
+
console.log(`[loopengine] imported ${n} scheduled task(s) from ${dir}`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
export class Scheduler {
|
|
246
|
+
fire;
|
|
247
|
+
tickMs;
|
|
248
|
+
onSettled;
|
|
249
|
+
inFlight = new Set();
|
|
250
|
+
timer;
|
|
251
|
+
constructor(options) {
|
|
252
|
+
this.fire = options.fire;
|
|
253
|
+
this.tickMs = options.tickMs ?? (Number(process.env.LOOPENGINE_SCHEDULER_TICK_MS) || 10_000);
|
|
254
|
+
this.onSettled = options.onSettled;
|
|
255
|
+
}
|
|
256
|
+
/** Imports tasks left by the old lp-task-scheduler ability, recovers
|
|
257
|
+
* runs a restart cut off, then ticks every `tickMs`. */
|
|
258
|
+
async start() {
|
|
259
|
+
await importLegacyAbilityTasks();
|
|
260
|
+
for (const task of await getSchedulerStore().list()) {
|
|
261
|
+
if (task.status === 'running')
|
|
262
|
+
await this.settle(task.task_id, new Date().toISOString(), { status: 'error', error: 'The server restarted while this run was in progress.' });
|
|
263
|
+
}
|
|
264
|
+
await this.tick();
|
|
265
|
+
this.timer = setInterval(() => void this.tick().catch((err) => console.error('[loopengine] scheduler tick failed:', err)), this.tickMs);
|
|
266
|
+
this.timer.unref?.();
|
|
267
|
+
}
|
|
268
|
+
stop() {
|
|
269
|
+
if (this.timer)
|
|
270
|
+
clearInterval(this.timer);
|
|
271
|
+
}
|
|
272
|
+
/** How many runs are executing right now (for idle-shutdown checks). */
|
|
273
|
+
get running() {
|
|
274
|
+
return this.inFlight.size;
|
|
275
|
+
}
|
|
276
|
+
/** Fires every task that's due. Safe to call any time (a host's wake-up
|
|
277
|
+
* request calls it directly). */
|
|
278
|
+
async tick(nowMs = Date.now()) {
|
|
279
|
+
let fired = 0;
|
|
280
|
+
for (const task of await getSchedulerStore().list()) {
|
|
281
|
+
if (task.status !== 'scheduled' || !task.next_run_at || Date.parse(task.next_run_at) > nowMs || this.inFlight.has(task.task_id))
|
|
282
|
+
continue;
|
|
283
|
+
fired++;
|
|
284
|
+
this.inFlight.add(task.task_id);
|
|
285
|
+
void this.run(task.task_id).finally(() => this.inFlight.delete(task.task_id));
|
|
286
|
+
}
|
|
287
|
+
return fired;
|
|
288
|
+
}
|
|
289
|
+
async run(taskId) {
|
|
290
|
+
const ranAt = new Date().toISOString();
|
|
291
|
+
const claimed = await getSchedulerStore().update(taskId, (t) => (t && t.status === 'scheduled' ? { ...t, status: 'running' } : t));
|
|
292
|
+
if (!claimed || claimed.status !== 'running')
|
|
293
|
+
return;
|
|
294
|
+
const sessionId = `sched-${taskId.slice(0, 8)}-${claimed.run_count + 1}-${Date.now().toString(36)}`;
|
|
295
|
+
try {
|
|
296
|
+
const outcome = await this.fire(claimed, sessionId);
|
|
297
|
+
if (outcome.kind === 'pending') {
|
|
298
|
+
await getSchedulerStore().update(taskId, (t) => t && {
|
|
299
|
+
...t,
|
|
300
|
+
status: 'awaiting_approval',
|
|
301
|
+
next_run_at: undefined,
|
|
302
|
+
pending: { ran_at: ranAt, session_id: sessionId, pending_ids: outcome.pendingIds },
|
|
303
|
+
// Cancelled while this run was starting: finish this run, then stop.
|
|
304
|
+
cancel_after_run: t.cancel_after_run || t.status === 'cancelled' || undefined,
|
|
305
|
+
});
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
await this.settle(taskId, ranAt, { status: 'ok', result: outcome.text, session_id: sessionId });
|
|
309
|
+
}
|
|
310
|
+
catch (err) {
|
|
311
|
+
await this.settle(taskId, ranAt, { status: 'error', error: err instanceof Error ? err.message : String(err), session_id: sessionId });
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
/** A paused run resumed after a decision (adapters/http.ts calls this
|
|
315
|
+
* from the resolve path): settle it, or record the next decision it's
|
|
316
|
+
* now waiting on. */
|
|
317
|
+
async settleFromResume(sessionId, outcome) {
|
|
318
|
+
if (!sessionId.startsWith('sched-'))
|
|
319
|
+
return;
|
|
320
|
+
const task = (await getSchedulerStore().list()).find((t) => t.pending?.session_id === sessionId);
|
|
321
|
+
if (!task)
|
|
322
|
+
return;
|
|
323
|
+
if (outcome.pendingIds?.length) {
|
|
324
|
+
await getSchedulerStore().update(task.task_id, (t) => t && t.pending && { ...t, pending: { ...t.pending, pending_ids: outcome.pendingIds } });
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
await this.settle(task.task_id, task.pending.ran_at, { status: 'ok', result: outcome.text, session_id: sessionId });
|
|
328
|
+
}
|
|
329
|
+
async settle(taskId, ranAt, run) {
|
|
330
|
+
const nowMs = Date.now();
|
|
331
|
+
const entry = { ran_at: ranAt, ...run };
|
|
332
|
+
const settled = await getSchedulerStore().update(taskId, (t) => {
|
|
333
|
+
if (!t)
|
|
334
|
+
return t;
|
|
335
|
+
const history = [...t.history, entry].slice(-HISTORY_LIMIT);
|
|
336
|
+
const base = {
|
|
337
|
+
...t,
|
|
338
|
+
run_count: t.run_count + 1,
|
|
339
|
+
last_run_at: ranAt,
|
|
340
|
+
last_status: run.status,
|
|
341
|
+
last_result: run.result,
|
|
342
|
+
last_error: run.error,
|
|
343
|
+
pending: undefined,
|
|
344
|
+
history,
|
|
345
|
+
run_now_from: undefined,
|
|
346
|
+
cancel_after_run: undefined,
|
|
347
|
+
};
|
|
348
|
+
if (t.cancel_after_run || t.status === 'cancelled')
|
|
349
|
+
return { ...base, status: 'cancelled', next_run_at: undefined };
|
|
350
|
+
// A "run now" of a paused or finished task goes back to how it was.
|
|
351
|
+
if (t.run_now_from)
|
|
352
|
+
return { ...base, status: t.run_now_from, next_run_at: undefined };
|
|
353
|
+
const next = nextRunAfter(t, nowMs);
|
|
354
|
+
return next === undefined ? { ...base, status: 'done', next_run_at: undefined } : { ...base, status: 'scheduled', next_run_at: new Date(next).toISOString() };
|
|
355
|
+
});
|
|
356
|
+
if (settled)
|
|
357
|
+
this.onSettled?.(settled, entry);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
import type { ToolDefinition } from '../agent-config.js';
|
|
2
2
|
export declare const systemTools: ToolDefinition[];
|
|
3
|
+
export { createScheduleTools } from './schedule.js';
|
|
3
4
|
export { createAskUserTool, listQuestions, answerQuestion, findQuestion, CliQuestionHandler, WebchatQuestionHandler, isDurableQuestionHandler, type PendingQuestion, } from './ask_user.js';
|
|
@@ -9,4 +9,5 @@
|
|
|
9
9
|
// re-exported here instead of an instance.
|
|
10
10
|
import { readFile } from './read_file.js';
|
|
11
11
|
export const systemTools = [readFile];
|
|
12
|
+
export { createScheduleTools } from './schedule.js';
|
|
12
13
|
export { createAskUserTool, listQuestions, answerQuestion, findQuestion, CliQuestionHandler, WebchatQuestionHandler, isDurableQuestionHandler, } from './ask_user.js';
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { cancelScheduledTask, createScheduledTask, getScheduledTask, listScheduledTasks, } from '../scheduler.js';
|
|
2
|
+
/** How many active (not done or cancelled) tasks one agent may create
|
|
3
|
+
* through these tools — each run is a model call, so a runaway loop of
|
|
4
|
+
* "schedule another one" stops here. The console and API aren't capped. */
|
|
5
|
+
function maxActivePerAgent() {
|
|
6
|
+
const n = Number(process.env.LOOPENGINE_SCHEDULER_MAX_PER_AGENT);
|
|
7
|
+
return Number.isInteger(n) && n > 0 ? n : 25;
|
|
8
|
+
}
|
|
9
|
+
function summary(t) {
|
|
10
|
+
return {
|
|
11
|
+
task_id: t.task_id,
|
|
12
|
+
task_name: t.task_name,
|
|
13
|
+
message: t.message,
|
|
14
|
+
schedule: t.schedule,
|
|
15
|
+
status: t.status,
|
|
16
|
+
next_run_at: t.next_run_at,
|
|
17
|
+
run_count: t.run_count,
|
|
18
|
+
last_status: t.last_status,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export function createScheduleTools(agentName) {
|
|
22
|
+
async function ownTask(taskId, tool) {
|
|
23
|
+
const task = await getScheduledTask(taskId);
|
|
24
|
+
// Another agent's task reads exactly like a missing one.
|
|
25
|
+
if (!task || task.agent !== agentName)
|
|
26
|
+
throw new Error(`${tool}: no scheduled task "${taskId}" for this agent`);
|
|
27
|
+
return task;
|
|
28
|
+
}
|
|
29
|
+
const scheduleTask = {
|
|
30
|
+
name: 'system_schedule_task',
|
|
31
|
+
description: 'Schedule a message to yourself for later — once at a set time, or repeatedly on an interval — like setting a reminder or a cron job. When it fires you receive the message as a new conversation (none of the current one carries over, so make the message self-contained) and handle it with your tools as usual, with no person live. A tool call that needs approval goes wherever your approval requests are sent and waits; if nowhere is set, it is denied. Use only when the user asks for something to happen later or regularly, never on your own initiative. Use the user\'s time zone (ask if unclear), prefer the longest interval that serves them since each run is a model call, and confirm the schedule back in plain words. Returns task_id and next_run_at.',
|
|
32
|
+
input_schema: {
|
|
33
|
+
type: 'object',
|
|
34
|
+
properties: {
|
|
35
|
+
message: {
|
|
36
|
+
type: 'string',
|
|
37
|
+
description: 'What you will receive when the task fires, written as an instruction to your future self with every detail it needs, e.g. "Check whether order 4417 has shipped and email the customer at a@example.com if it has."',
|
|
38
|
+
},
|
|
39
|
+
schedule: {
|
|
40
|
+
type: 'object',
|
|
41
|
+
description: 'Either { "run_at": "<ISO 8601 timestamp with time zone>" } for once, or { "every": "<interval>" } to repeat until cancelled — a number plus s/m/h/d, e.g. "30m", "1h", "1d"; 30s at the shortest. A repeating task keeps its first slot: started at 10:07 with "1h", it fires at 11:07, 12:07, ...',
|
|
42
|
+
properties: { run_at: { type: 'string' }, every: { type: 'string' } },
|
|
43
|
+
},
|
|
44
|
+
task_name: { type: 'string', description: 'Optional short label, shown when listing tasks and in the console.' },
|
|
45
|
+
notify: { type: 'boolean', description: 'Optional — also send each run\'s result through your notifier (where your approval requests go), so a person sees it.' },
|
|
46
|
+
},
|
|
47
|
+
required: ['message', 'schedule'],
|
|
48
|
+
},
|
|
49
|
+
execute: async (input) => {
|
|
50
|
+
const active = (await listScheduledTasks({ agent: agentName })).filter((t) => t.status !== 'done' && t.status !== 'cancelled');
|
|
51
|
+
if (active.length >= maxActivePerAgent()) {
|
|
52
|
+
throw new Error(`system_schedule_task: you already have ${active.length} active scheduled tasks (the limit is ${maxActivePerAgent()}); cancel one you no longer need first`);
|
|
53
|
+
}
|
|
54
|
+
const task = await createScheduledTask({
|
|
55
|
+
agent: agentName,
|
|
56
|
+
message: String(input.message ?? ''),
|
|
57
|
+
schedule: (input.schedule ?? {}),
|
|
58
|
+
task_name: typeof input.task_name === 'string' ? input.task_name : undefined,
|
|
59
|
+
notify: input.notify === true,
|
|
60
|
+
});
|
|
61
|
+
return JSON.stringify({ task_id: task.task_id, next_run_at: task.next_run_at, schedule: task.schedule });
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
const listTasks = {
|
|
65
|
+
name: 'system_list_scheduled_tasks',
|
|
66
|
+
description: 'List your own scheduled tasks (task_id, name, message, schedule, status, next_run_at, run_count, last_status). Check this before scheduling something recurring, so you don\'t create a duplicate. status "awaiting_approval" means a run is waiting on someone to approve or answer.',
|
|
67
|
+
input_schema: {
|
|
68
|
+
type: 'object',
|
|
69
|
+
properties: {
|
|
70
|
+
status: {
|
|
71
|
+
type: 'string',
|
|
72
|
+
enum: ['scheduled', 'running', 'awaiting_approval', 'paused', 'done', 'cancelled'],
|
|
73
|
+
description: 'Optional — only tasks with this status.',
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
execute: async (input) => {
|
|
78
|
+
const status = typeof input.status === 'string' ? input.status : undefined;
|
|
79
|
+
return JSON.stringify((await listScheduledTasks({ agent: agentName, status })).map(summary));
|
|
80
|
+
},
|
|
81
|
+
safe: true,
|
|
82
|
+
};
|
|
83
|
+
const checkTask = {
|
|
84
|
+
name: 'system_check_scheduled_task',
|
|
85
|
+
description: 'Get one of your scheduled tasks in full — schedule, status, next_run_at, and recent runs, each "ok" with your reply from that run or "error" with what went wrong (for example, a call that needed approval was denied because nowhere is set to send approval requests).',
|
|
86
|
+
input_schema: {
|
|
87
|
+
type: 'object',
|
|
88
|
+
properties: { task_id: { type: 'string', description: 'The task_id from system_schedule_task or system_list_scheduled_tasks.' } },
|
|
89
|
+
required: ['task_id'],
|
|
90
|
+
},
|
|
91
|
+
execute: async (input) => JSON.stringify(await ownTask(String(input.task_id ?? ''), 'system_check_scheduled_task')),
|
|
92
|
+
safe: true,
|
|
93
|
+
};
|
|
94
|
+
const cancelTask = {
|
|
95
|
+
name: 'system_cancel_scheduled_task',
|
|
96
|
+
description: 'Cancel one of your scheduled tasks so it doesn\'t fire again. A run already waiting on an approval finishes first. Its history stays (status "cancelled").',
|
|
97
|
+
input_schema: {
|
|
98
|
+
type: 'object',
|
|
99
|
+
properties: { task_id: { type: 'string', description: 'The task_id from system_schedule_task or system_list_scheduled_tasks.' } },
|
|
100
|
+
required: ['task_id'],
|
|
101
|
+
},
|
|
102
|
+
execute: async (input) => {
|
|
103
|
+
const task = await ownTask(String(input.task_id ?? ''), 'system_cancel_scheduled_task');
|
|
104
|
+
if (task.status === 'cancelled')
|
|
105
|
+
return JSON.stringify({ task_id: task.task_id, status: 'cancelled', already: true });
|
|
106
|
+
const after = await cancelScheduledTask(task.task_id);
|
|
107
|
+
return JSON.stringify({ task_id: task.task_id, status: after.status, ...(after.cancel_after_run ? { note: 'cancels once the run waiting on an approval finishes' } : {}) });
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
return [scheduleTask, listTasks, checkTask, cancelTask];
|
|
111
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -10,12 +10,13 @@ export { setUsageSink, recordUsage, type UsageEvent, type UsageSink } from '#cor
|
|
|
10
10
|
export { readNotifierFile, writeNotifierFile, removeNotifierFile, validateNotifierFile, notifierConfigFromFile, NotifierFileError, NOTIFIER_SECRET_VARS, type NotifierFile } from '#core/notifier-file.js';
|
|
11
11
|
export { listDeliveries, recordDelivery, type NotifierDelivery } from '#core/notifier-deliveries.js';
|
|
12
12
|
export { describeNotifier } from '#core/http-notifier.js';
|
|
13
|
+
export { Scheduler, ScheduleError, FileSchedulerStore, getSchedulerStore, setSchedulerStore, createScheduledTask, listScheduledTasks, getScheduledTask, cancelScheduledTask, pauseScheduledTask, resumeScheduledTask, runScheduledTaskNow, deleteScheduledTask, nextScheduledWakeAt, importLegacyScheduledTasks, parseEvery, describeSchedule, type ScheduledTask, type ScheduledTaskRun, type ScheduledTaskStatus, type Schedule, type SchedulerStore, type FireTask, type CreateScheduledTaskInput } from '#core/scheduler.js';
|
|
13
14
|
export { verifySlackRequest, parseSlackInteraction, openSlackAnswerModal, replyInSlack, slackOutcomeText, listSlackChannels, slackAppManifest, type SlackInteraction, type SlackChannel } from '#core/slack-interactions.js';
|
|
14
15
|
export { NOTIFIER_SLACK_SIGNING_SECRET_VAR } from '#core/notifier-file.js';
|
|
15
16
|
export { createDecisionToken, verifyDecisionToken, decidePath, decideLinkMaker, decisionLinkTtlMs, type DecisionKind } from '#core/approval-links.js';
|
|
16
17
|
export { renderDecidePage, renderDecideGonePage, renderDecideInvalidPage, type DecidePageItem } from './web/decide-page.js';
|
|
17
18
|
export { sendTestWebhook } from '#core/http-notify-triggers/webhook.js';
|
|
18
|
-
export { describeAgentNotifier, saveAgentNotifier, removeAgentNotifier, testAgentNotifier, listAgentPending, slackCredentialsFor, listAgentSlackChannels, type DefaultWebhook } from './web/notifier-admin.js';
|
|
19
|
+
export { describeAgentNotifier, saveAgentNotifier, removeAgentNotifier, testAgentNotifier, listAgentPending, slackCredentialsFor, listAgentSlackChannels, announceToNotifier, type DefaultWebhook } from './web/notifier-admin.js';
|
|
19
20
|
export type * from './core/loop-events.js';
|
|
20
21
|
export { streamMessage, streamMessageWithCallbacks, sendMessage, approveCall, denyCall, answerQuestion, getSessionHistory, } from './core/client.js';
|
|
21
22
|
export type { RequestOptions, SendMessageResult, PendingApprovalResult, PendingQuestionResult, PendingResult, MessageResult, StreamMessageCallbacks, } from './core/client.js';
|
|
@@ -56,7 +57,7 @@ export type { ToolSource, ComposioSourceOptions } from './core/mcpplug.js';
|
|
|
56
57
|
export { ToolLane, buildLanes, runLanes } from './core/toollane.js';
|
|
57
58
|
export type { ToolLaneOptions, SafetyClassifier, ToolCall, Lane, LaneResult } from './core/toollane.js';
|
|
58
59
|
export { WebhookNotifier } from './core/http-notify-triggers/webhook.js';
|
|
59
|
-
export { listQuestions, answerQuestion as resolvePendingQuestion, findQuestion, createAskUserTool } from './core/system-tools/index.js';
|
|
60
|
+
export { listQuestions, answerQuestion as resolvePendingQuestion, findQuestion, createAskUserTool, createScheduleTools } from './core/system-tools/index.js';
|
|
60
61
|
export type { PendingQuestion } from './core/system-tools/index.js';
|
|
61
62
|
export { editAgentFile, AgentEditNotSupportedError, AgentFileNotFoundError } from './web/agent-file-admin.js';
|
|
62
63
|
export type { AgentEditableFields, AgentEditResult } from './web/agent-file-admin.js';
|
package/dist/index.js
CHANGED
|
@@ -12,12 +12,13 @@ export { setUsageSink, recordUsage } from '#core/usage.js';
|
|
|
12
12
|
export { readNotifierFile, writeNotifierFile, removeNotifierFile, validateNotifierFile, notifierConfigFromFile, NotifierFileError, NOTIFIER_SECRET_VARS } from '#core/notifier-file.js';
|
|
13
13
|
export { listDeliveries, recordDelivery } from '#core/notifier-deliveries.js';
|
|
14
14
|
export { describeNotifier } from '#core/http-notifier.js';
|
|
15
|
+
export { Scheduler, ScheduleError, FileSchedulerStore, getSchedulerStore, setSchedulerStore, createScheduledTask, listScheduledTasks, getScheduledTask, cancelScheduledTask, pauseScheduledTask, resumeScheduledTask, runScheduledTaskNow, deleteScheduledTask, nextScheduledWakeAt, importLegacyScheduledTasks, parseEvery, describeSchedule } from '#core/scheduler.js';
|
|
15
16
|
export { verifySlackRequest, parseSlackInteraction, openSlackAnswerModal, replyInSlack, slackOutcomeText, listSlackChannels, slackAppManifest } from '#core/slack-interactions.js';
|
|
16
17
|
export { NOTIFIER_SLACK_SIGNING_SECRET_VAR } from '#core/notifier-file.js';
|
|
17
18
|
export { createDecisionToken, verifyDecisionToken, decidePath, decideLinkMaker, decisionLinkTtlMs } from '#core/approval-links.js';
|
|
18
19
|
export { renderDecidePage, renderDecideGonePage, renderDecideInvalidPage } from './web/decide-page.js';
|
|
19
20
|
export { sendTestWebhook } from '#core/http-notify-triggers/webhook.js';
|
|
20
|
-
export { describeAgentNotifier, saveAgentNotifier, removeAgentNotifier, testAgentNotifier, listAgentPending, slackCredentialsFor, listAgentSlackChannels } from './web/notifier-admin.js';
|
|
21
|
+
export { describeAgentNotifier, saveAgentNotifier, removeAgentNotifier, testAgentNotifier, listAgentPending, slackCredentialsFor, listAgentSlackChannels, announceToNotifier } from './web/notifier-admin.js';
|
|
21
22
|
// The framework-agnostic browser client for adapters/http.ts's own wire
|
|
22
23
|
// protocol (see client.ts's own header comment) — this, not a deep
|
|
23
24
|
// subpath import, is how a consumer's React/Vue/etc. chat UI is meant to
|
|
@@ -55,7 +56,7 @@ export { WebhookNotifier } from './core/http-notify-triggers/webhook.js';
|
|
|
55
56
|
// answerQuestion above (core/client.ts) is the HTTP client-side helper —
|
|
56
57
|
// this is the server-side function that actually resolves a pending
|
|
57
58
|
// question in-process, aliased to avoid colliding with that name.
|
|
58
|
-
export { listQuestions, answerQuestion as resolvePendingQuestion, findQuestion, createAskUserTool } from './core/system-tools/index.js';
|
|
59
|
+
export { listQuestions, answerQuestion as resolvePendingQuestion, findQuestion, createAskUserTool, createScheduleTools } from './core/system-tools/index.js';
|
|
59
60
|
export { editAgentFile, AgentEditNotSupportedError, AgentFileNotFoundError } from './web/agent-file-admin.js';
|
|
60
61
|
export { createHttpTool, updateHttpTool, readHttpToolSpec, listEditableHttpToolNames, HttpToolNameError, HttpToolExistsError, HttpToolIndexShapeError, HttpToolNotFoundError, HttpToolNotEditableError, } from './web/http-tool-admin.js';
|
|
61
62
|
export { agentsConfigPageHtml } from './web/agents-config-page.js';
|
|
@@ -39,6 +39,13 @@ export const agentsConfigPageHtml = `<!doctype html>
|
|
|
39
39
|
panels' own content. */
|
|
40
40
|
#empty { color: var(--ink-muted); font-size: 13px; padding: 24px; }
|
|
41
41
|
.env-cell { display: grid; gap: 6px; }
|
|
42
|
+
.schedule-result { max-width: 420px; white-space: pre-wrap; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; font-size: 12.5px; }
|
|
43
|
+
.schedule-history { list-style: none; margin: 6px 0 0; padding: 0; display: grid; gap: 8px; }
|
|
44
|
+
form.add-source .schedule-when { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 13px; }
|
|
45
|
+
form.add-source .schedule-when label { display: inline; font-size: 13px; }
|
|
46
|
+
form.add-source .schedule-when input[type="radio"] { width: auto; margin: 0; }
|
|
47
|
+
form.add-source .schedule-when input[type="number"] { width: 80px; }
|
|
48
|
+
form.add-source .schedule-when select, form.add-source .schedule-when input[type="datetime-local"] { width: auto; }
|
|
42
49
|
.approvals-nudge { border: 1px solid var(--warn); background: var(--warn-soft); color: var(--ink); border-radius: var(--radius); padding: 12px 14px; margin-bottom: 24px; font-size: 13px; }
|
|
43
50
|
.env-desc { max-width: 340px; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
|
44
51
|
form.add-source.env-inline { border: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px; max-width: 320px; }
|
|
@@ -1263,6 +1270,151 @@ ${consoleShellScript}
|
|
|
1263
1270
|
});
|
|
1264
1271
|
}
|
|
1265
1272
|
|
|
1273
|
+
// ---- Schedules tab: messages this agent receives on a timer (core/
|
|
1274
|
+
// scheduler.ts behind GET/POST .../schedules and POST /schedules/:id/
|
|
1275
|
+
// run|pause|resume|cancel). Reloaded every time the tab opens, and
|
|
1276
|
+
// every few seconds while a run is in progress. ----
|
|
1277
|
+
|
|
1278
|
+
var schedulesRefreshTimer = null;
|
|
1279
|
+
|
|
1280
|
+
function schedulesContentEl() {
|
|
1281
|
+
return detail.querySelector('#schedulesContent');
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
function renderSchedulesTabPlaceholder() {
|
|
1285
|
+
return '<div id="schedulesContent"><p class="hint">Loading…</p></div>';
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
var SCHEDULE_PILLS = {
|
|
1289
|
+
scheduled: 'good', running: 'accent', awaiting_approval: 'ask', paused: 'neutral', done: 'neutral', cancelled: 'neutral'
|
|
1290
|
+
};
|
|
1291
|
+
var SCHEDULE_LABELS = {
|
|
1292
|
+
scheduled: 'scheduled', running: 'running', awaiting_approval: 'waiting for approval', paused: 'paused', done: 'done', cancelled: 'cancelled'
|
|
1293
|
+
};
|
|
1294
|
+
|
|
1295
|
+
function scheduleText(s) {
|
|
1296
|
+
return s.run_at ? 'Once, ' + new Date(s.run_at).toLocaleString() : 'Every ' + escapeHtml(s.every);
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
function renderScheduleRow(t) {
|
|
1300
|
+
var actions = [];
|
|
1301
|
+
if (t.status !== 'running' && t.status !== 'awaiting_approval' && t.status !== 'cancelled') actions.push(['run', 'Run now']);
|
|
1302
|
+
if (t.status === 'scheduled') actions.push(['pause', 'Pause']);
|
|
1303
|
+
if (t.status === 'paused') actions.push(['resume', 'Resume']);
|
|
1304
|
+
if (t.status !== 'cancelled' && t.status !== 'done') actions.push(['cancel', 'Cancel']);
|
|
1305
|
+
if (t.status === 'cancelled' || t.status === 'done') actions.push(['delete', 'Delete']);
|
|
1306
|
+
var last = t.last_run_at
|
|
1307
|
+
? '<span class="le-pill ' + (t.last_status === 'ok' ? 'good' : 'bad') + '">' + escapeHtml(t.last_status) + '</span> ' +
|
|
1308
|
+
'<span class="hint">' + escapeHtml(new Date(t.last_run_at).toLocaleString()) + '</span>' +
|
|
1309
|
+
'<div class="schedule-result">' + escapeHtml(t.last_status === 'ok' ? (t.last_result || '') : (t.last_error || '')) + '</div>'
|
|
1310
|
+
: '<span class="hint">not run yet</span>';
|
|
1311
|
+
var history = (t.history || []).slice().reverse().map(function (h) {
|
|
1312
|
+
return '<li><span class="le-pill ' + (h.status === 'ok' ? 'good' : 'bad') + '">' + escapeHtml(h.status) + '</span> <span class="hint">' + escapeHtml(new Date(h.ran_at).toLocaleString()) + '</span>' +
|
|
1313
|
+
'<div class="schedule-result">' + escapeHtml(h.status === 'ok' ? (h.result || '') : (h.error || '')) + '</div></li>';
|
|
1314
|
+
}).join('');
|
|
1315
|
+
return '<tr>' +
|
|
1316
|
+
'<td style="min-width:180px"><strong>' + escapeHtml(t.task_name || 'Untitled') + '</strong>' + (t.notify ? ' <span class="le-pill accent" title="Each result goes through this agent's notifier">notifies</span>' : '') +
|
|
1317
|
+
'<div class="schedule-result hint">' + escapeHtml(t.message) + '</div></td>' +
|
|
1318
|
+
'<td>' + scheduleText(t.schedule) + (t.next_run_at ? '<div class="hint">next ' + escapeHtml(new Date(t.next_run_at).toLocaleString()) + '</div>' : '') + '</td>' +
|
|
1319
|
+
'<td><span class="le-pill ' + (SCHEDULE_PILLS[t.status] || 'neutral') + '">' + escapeHtml(SCHEDULE_LABELS[t.status] || t.status) + '</span>' +
|
|
1320
|
+
(t.status === 'awaiting_approval' ? '<div class="hint"><a href="#" class="schedule-to-approvals">Decide in Permissions</a></div>' : '') + '</td>' +
|
|
1321
|
+
'<td style="min-width:200px">' + last + (history ? '<details><summary class="hint">History (' + (t.history || []).length + ')</summary><ul class="schedule-history">' + history + '</ul></details>' : '') + '</td>' +
|
|
1322
|
+
'<td><div style="display:flex;gap:6px;flex-wrap:wrap">' + actions.map(function (a) {
|
|
1323
|
+
return '<button type="button" class="schedule-action' + (a[0] === 'run' ? ' primary' : '') + (a[0] === 'cancel' || a[0] === 'delete' ? ' delete-btn' : '') + '" data-id="' + escapeHtml(t.task_id) + '" data-action="' + a[0] + '">' + a[1] + '</button>';
|
|
1324
|
+
}).join('') + '</div></td></tr>';
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
function renderSchedulesHtml(tasks) {
|
|
1328
|
+
var active = tasks.filter(function (t) { return t.status !== 'cancelled' && t.status !== 'done'; });
|
|
1329
|
+
var list = tasks.length
|
|
1330
|
+
? '<div class="le-table-wrap"><table><thead><tr><th>Task</th><th>When</th><th>Status</th><th>Last run</th><th></th></tr></thead><tbody>' + tasks.map(renderScheduleRow).join('') + '</tbody></table></div>'
|
|
1331
|
+
: '<p class="hint">Nothing scheduled yet.</p>';
|
|
1332
|
+
return '<p class="muted" style="margin:0 0 20px">Messages this agent receives on a timer, like a cron job. Each run works like an app calling it over HTTP: an approval goes wherever the Permissions tab sends it, and with nowhere set, a call that needs approval is denied. The agent can also schedule its own tasks when asked in Chat (“remind me tomorrow at 9”); they appear here too.</p>' +
|
|
1333
|
+
'<section><h3>Scheduled (' + active.length + ' active)</h3>' + list + '</section>' +
|
|
1334
|
+
'<section><h3>New schedule</h3>' +
|
|
1335
|
+
'<form class="add-source" id="scheduleForm" style="max-width:560px">' +
|
|
1336
|
+
'<label>Message <span class="hint">(what the agent receives each time, as if someone typed it)</span><textarea name="message" required placeholder="Summarize yesterday's support tickets and post the summary."></textarea></label>' +
|
|
1337
|
+
'<div class="body-field">When' +
|
|
1338
|
+
'<div class="schedule-when"><input type="radio" name="when" value="every" id="whenEvery" checked><label for="whenEvery">Every</label>' +
|
|
1339
|
+
'<input type="number" name="everyN" value="1" min="1" aria-label="How many">' +
|
|
1340
|
+
'<select name="everyUnit" aria-label="Unit"><option value="m">minutes</option><option value="h" selected>hours</option><option value="d">days</option></select></div>' +
|
|
1341
|
+
'<div class="schedule-when"><input type="radio" name="when" value="once" id="whenOnce"><label for="whenOnce">Once at</label>' +
|
|
1342
|
+
'<input type="datetime-local" name="runAt" aria-label="Date and time"></div>' +
|
|
1343
|
+
'</div>' +
|
|
1344
|
+
'<label>Name <span class="hint">(optional)</span><input name="task_name" placeholder="Daily ticket summary"></label>' +
|
|
1345
|
+
'<label class="http-tool-inline-label"><input type="checkbox" name="notify"> Send each result through this agent's notifier (set in the Permissions tab)</label>' +
|
|
1346
|
+
'<div style="display:flex;gap:8px"><button type="submit">Schedule</button></div>' +
|
|
1347
|
+
'<div id="scheduleResult" class="hint"></div>' +
|
|
1348
|
+
'</form></section>';
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
function wireSchedulesHandlers(name) {
|
|
1352
|
+
var content = schedulesContentEl();
|
|
1353
|
+
if (!content) return;
|
|
1354
|
+
var actionBtns = content.querySelectorAll('.schedule-action');
|
|
1355
|
+
for (var i = 0; i < actionBtns.length; i++) {
|
|
1356
|
+
actionBtns[i].addEventListener('click', function (ev) {
|
|
1357
|
+
var btn = ev.currentTarget;
|
|
1358
|
+
var action = btn.dataset.action;
|
|
1359
|
+
if ((action === 'cancel' || action === 'delete') && btn.dataset.confirming !== '1') {
|
|
1360
|
+
btn.dataset.confirming = '1';
|
|
1361
|
+
btn.textContent = 'Click again to ' + action;
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
btn.disabled = true;
|
|
1365
|
+
var request = action === 'delete'
|
|
1366
|
+
? fetchJsonRetryOnce('/schedules/' + encodeURIComponent(btn.dataset.id), { method: 'DELETE' })
|
|
1367
|
+
: fetchJsonRetryOnce('/schedules/' + encodeURIComponent(btn.dataset.id) + '/' + action, { method: 'POST' });
|
|
1368
|
+
request.then(function () { setTimeout(function () { loadSchedulesTab(name); }, action === 'run' ? 800 : 0); });
|
|
1369
|
+
});
|
|
1370
|
+
}
|
|
1371
|
+
var toApprovals = content.querySelectorAll('.schedule-to-approvals');
|
|
1372
|
+
for (var j = 0; j < toApprovals.length; j++) {
|
|
1373
|
+
toApprovals[j].addEventListener('click', function (ev) { ev.preventDefault(); switchTab('actauth'); });
|
|
1374
|
+
}
|
|
1375
|
+
var form = content.querySelector('#scheduleForm');
|
|
1376
|
+
var result = content.querySelector('#scheduleResult');
|
|
1377
|
+
form.addEventListener('submit', function (ev) {
|
|
1378
|
+
ev.preventDefault();
|
|
1379
|
+
var data = new FormData(form);
|
|
1380
|
+
var schedule;
|
|
1381
|
+
if (data.get('when') === 'once') {
|
|
1382
|
+
if (!data.get('runAt')) { result.textContent = 'Pick a date and time.'; return; }
|
|
1383
|
+
schedule = { run_at: new Date(data.get('runAt')).toISOString() };
|
|
1384
|
+
} else {
|
|
1385
|
+
schedule = { every: String(data.get('everyN')) + data.get('everyUnit') };
|
|
1386
|
+
}
|
|
1387
|
+
result.textContent = 'Scheduling' + String.fromCharCode(8230);
|
|
1388
|
+
fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/schedules', {
|
|
1389
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
1390
|
+
body: JSON.stringify({ message: data.get('message'), schedule: schedule, task_name: data.get('task_name'), notify: data.get('notify') === 'on' }),
|
|
1391
|
+
}).then(function (r) {
|
|
1392
|
+
if (!r.ok) { result.textContent = r.body.error || 'Could not schedule'; return; }
|
|
1393
|
+
loadSchedulesTab(name);
|
|
1394
|
+
});
|
|
1395
|
+
});
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
function loadSchedulesTab(name) {
|
|
1399
|
+
var content = schedulesContentEl();
|
|
1400
|
+
if (!content) return;
|
|
1401
|
+
if (schedulesRefreshTimer) { clearTimeout(schedulesRefreshTimer); schedulesRefreshTimer = null; }
|
|
1402
|
+
fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/schedules').then(function (r) {
|
|
1403
|
+
if (currentName !== name) return;
|
|
1404
|
+
if (!r.ok) throw new Error(r.body.error || 'request failed');
|
|
1405
|
+
var tasks = r.body.tasks || [];
|
|
1406
|
+
content.innerHTML = renderSchedulesHtml(tasks);
|
|
1407
|
+
wireSchedulesHandlers(name);
|
|
1408
|
+
leShell.setCount('schedules', tasks.filter(function (t) { return t.status !== 'cancelled' && t.status !== 'done'; }).length);
|
|
1409
|
+
if (tasks.some(function (t) { return t.status === 'running'; }) && currentTab === 'schedules') {
|
|
1410
|
+
schedulesRefreshTimer = setTimeout(function () { loadSchedulesTab(name); }, 3000);
|
|
1411
|
+
}
|
|
1412
|
+
}).catch(function (err) {
|
|
1413
|
+
if (currentName !== name) return;
|
|
1414
|
+
content.innerHTML = '<p class="error">Could not load schedules: ' + escapeHtml(err.message) + '</p>';
|
|
1415
|
+
});
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1266
1418
|
function actauthContentEl() {
|
|
1267
1419
|
return detail.querySelector('#actauthContent');
|
|
1268
1420
|
}
|
|
@@ -2737,7 +2889,7 @@ ${consoleShellScript}
|
|
|
2737
2889
|
|
|
2738
2890
|
// ---- Tab switching + agent selection ----
|
|
2739
2891
|
|
|
2740
|
-
var CONFIG_TABS = ['overview', 'tools', 'skills', 'abilities', 'actauth', 'env'];
|
|
2892
|
+
var CONFIG_TABS = ['overview', 'tools', 'skills', 'abilities', 'actauth', 'schedules', 'env'];
|
|
2741
2893
|
|
|
2742
2894
|
function switchTab(tab) {
|
|
2743
2895
|
if (CONFIG_TABS.indexOf(tab) === -1) tab = 'overview';
|
|
@@ -2765,6 +2917,7 @@ ${consoleShellScript}
|
|
|
2765
2917
|
if (tab === 'abilities' && abilitiesLoadedFor !== currentName) {
|
|
2766
2918
|
loadAbilitiesTab(currentName);
|
|
2767
2919
|
}
|
|
2920
|
+
if (tab === 'schedules') loadSchedulesTab(currentName);
|
|
2768
2921
|
}
|
|
2769
2922
|
|
|
2770
2923
|
// Refreshes just Overview's own summary — used after anything that
|
|
@@ -2861,6 +3014,7 @@ ${consoleShellScript}
|
|
|
2861
3014
|
'<div class="tab-panel" data-tab-panel="actauth">' + renderActauthTabPlaceholder() + '</div>' +
|
|
2862
3015
|
'<div class="tab-panel" data-tab-panel="env">' + renderEnvTabPlaceholder() + '</div>' +
|
|
2863
3016
|
'<div class="tab-panel" data-tab-panel="abilities">' + renderAbilitiesTabPlaceholder() + '</div>' +
|
|
3017
|
+
'<div class="tab-panel" data-tab-panel="schedules">' + renderSchedulesTabPlaceholder() + '</div>' +
|
|
2864
3018
|
'</div></div>';
|
|
2865
3019
|
|
|
2866
3020
|
wireSkillsHandlers(cfg.name);
|
|
@@ -42,7 +42,7 @@ export const consoleShellScript = `<script>
|
|
|
42
42
|
var toSet = {};
|
|
43
43
|
var TABS = [
|
|
44
44
|
['chat', 'Chat'], ['overview', 'Overview'], ['abilities', 'Abilities'], ['tools', 'Tools'],
|
|
45
|
-
['skills', 'Skills'], ['actauth', 'Permissions'], ['env', 'Environment']
|
|
45
|
+
['skills', 'Skills'], ['actauth', 'Permissions'], ['schedules', 'Schedules'], ['env', 'Environment']
|
|
46
46
|
];
|
|
47
47
|
|
|
48
48
|
function esc(s) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { AgentConfig } from '../core/agent-config.js';
|
|
2
2
|
import type { CheckpointStore } from '../core/durable-approvals.js';
|
|
3
3
|
import { type DeliveryResult } from '../core/http-notify-triggers/webhook.js';
|
|
4
|
+
import type { HttpNotifierConfig } from '../core/agent-config.js';
|
|
4
5
|
import { type NotifierFileChannel } from '../core/notifier-file.js';
|
|
5
6
|
import { type SlackChannel } from '../core/slack-interactions.js';
|
|
6
7
|
/** The deployment-wide default webhook (LOOPENGINE_DEFAULT_WEBHOOK_URL /
|
|
@@ -25,7 +26,7 @@ export declare function describeAgentNotifier(config: AgentConfig, defaultWebhoo
|
|
|
25
26
|
manifestTemplate: Record<string, unknown>;
|
|
26
27
|
};
|
|
27
28
|
deliveries: import("../core/notifier-deliveries.js").NotifierDelivery[];
|
|
28
|
-
channel?:
|
|
29
|
+
channel?: HttpNotifierConfig["channel"];
|
|
29
30
|
events?: string[];
|
|
30
31
|
file?: import("../core/notifier-file.js").NotifierFile;
|
|
31
32
|
missingSecret?: string;
|
|
@@ -49,6 +50,11 @@ export declare function listAgentSlackChannels(config: AgentConfig, botToken?: s
|
|
|
49
50
|
/** Removes notifier.yml; the stored secret is left alone, so turning the
|
|
50
51
|
* notifier back on doesn't need it re-entered. */
|
|
51
52
|
export declare function removeAgentNotifier(config: AgentConfig): void;
|
|
53
|
+
/** Posts a plain announcement through the agent's notifier — a scheduled
|
|
54
|
+
* task's result, for one — regardless of which events it subscribed to.
|
|
55
|
+
* A webhook gets a signed `run_finish` payload with `data` merged in;
|
|
56
|
+
* Slack and Lark get `text`. Does nothing when no notifier applies. */
|
|
57
|
+
export declare function announceToNotifier(config: AgentConfig, text: string, data: Record<string, unknown>, defaultWebhook?: DefaultWebhook): boolean;
|
|
52
58
|
/** Sends a test through whichever notifier applies to this agent —
|
|
53
59
|
* webhook or Slack; reports what the receiver answered. */
|
|
54
60
|
export declare function testAgentNotifier(config: AgentConfig, defaultWebhook?: DefaultWebhook): Promise<DeliveryResult & {
|
|
@@ -2,7 +2,9 @@ import { agentDirFor, createAgentEnv } from '../core/agent-env.js';
|
|
|
2
2
|
import { decidePath } from '../core/approval-links.js';
|
|
3
3
|
import { describeNotifier } from '../core/http-notifier.js';
|
|
4
4
|
import { sendTestSlackMessage } from '../core/http-notify-triggers/slack.js';
|
|
5
|
-
import { sendTestWebhook } from '../core/http-notify-triggers/webhook.js';
|
|
5
|
+
import { postLifecycleWebhook, sendTestWebhook } from '../core/http-notify-triggers/webhook.js';
|
|
6
|
+
import { SlackNotifier } from '../core/http-notify-triggers/slack.js';
|
|
7
|
+
import { LarkNotifier } from '../core/http-notify-triggers/lark.js';
|
|
6
8
|
import { listDeliveries } from '../core/notifier-deliveries.js';
|
|
7
9
|
import { NOTIFIER_SECRET_VARS, NOTIFIER_SLACK_SIGNING_SECRET_VAR, notifierConfigFromFile, readNotifierFile, removeNotifierFile, writeNotifierFile } from '../core/notifier-file.js';
|
|
8
10
|
import { listSlackChannels, slackAppManifest } from '../core/slack-interactions.js';
|
|
@@ -65,6 +67,49 @@ export async function listAgentSlackChannels(config, botToken) {
|
|
|
65
67
|
export function removeAgentNotifier(config) {
|
|
66
68
|
removeNotifierFile(agentDirFor(config));
|
|
67
69
|
}
|
|
70
|
+
/** The notifier settings that apply to an agent: its code httpNotifier,
|
|
71
|
+
* else its notifier.yml (once its secret is set), else the
|
|
72
|
+
* deployment-wide default webhook. */
|
|
73
|
+
function activeNotifierTarget(config, defaultWebhook) {
|
|
74
|
+
if (config.httpNotifier)
|
|
75
|
+
return config.httpNotifier;
|
|
76
|
+
const dir = agentDirFor(config);
|
|
77
|
+
const file = readNotifierFile(dir);
|
|
78
|
+
if (file) {
|
|
79
|
+
const { config: fromFile } = notifierConfigFromFile(file, createAgentEnv(config.name, dir));
|
|
80
|
+
if (fromFile)
|
|
81
|
+
return fromFile;
|
|
82
|
+
}
|
|
83
|
+
return defaultWebhook ? { channel: 'webhook', config: { webhookUrl: defaultWebhook.webhookUrl, webhookSecret: defaultWebhook.webhookSecret }, events: ['run_finish'] } : undefined;
|
|
84
|
+
}
|
|
85
|
+
/** Posts a plain announcement through the agent's notifier — a scheduled
|
|
86
|
+
* task's result, for one — regardless of which events it subscribed to.
|
|
87
|
+
* A webhook gets a signed `run_finish` payload with `data` merged in;
|
|
88
|
+
* Slack and Lark get `text`. Does nothing when no notifier applies. */
|
|
89
|
+
export function announceToNotifier(config, text, data, defaultWebhook) {
|
|
90
|
+
let target;
|
|
91
|
+
try {
|
|
92
|
+
target = activeNotifierTarget(config, defaultWebhook);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
if (!target)
|
|
98
|
+
return false;
|
|
99
|
+
switch (target.channel) {
|
|
100
|
+
case 'webhook':
|
|
101
|
+
postLifecycleWebhook(target.config.webhookUrl, target.config.webhookSecret, 'run_finish', { agent: config.name, text, ...data });
|
|
102
|
+
return true;
|
|
103
|
+
case 'slack':
|
|
104
|
+
new SlackNotifier({ botToken: target.config.botToken, channelId: target.config.channelId }).postMessage(text);
|
|
105
|
+
return true;
|
|
106
|
+
case 'lark':
|
|
107
|
+
new LarkNotifier({ appId: target.config.appId, appSecret: target.config.appSecret, chatId: target.config.chatId }).postMessage(text);
|
|
108
|
+
return true;
|
|
109
|
+
default:
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
68
113
|
/** Sends a test through whichever notifier applies to this agent —
|
|
69
114
|
* webhook or Slack; reports what the receiver answered. */
|
|
70
115
|
export async function testAgentNotifier(config, defaultWebhook) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "loopengine",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.70",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Runtime for tool-using AI agents with a transparent ReAct loop.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -52,6 +52,8 @@
|
|
|
52
52
|
"dist/core/approval-links.d.ts",
|
|
53
53
|
"dist/core/slack-interactions.js",
|
|
54
54
|
"dist/core/slack-interactions.d.ts",
|
|
55
|
+
"dist/core/scheduler.js",
|
|
56
|
+
"dist/core/scheduler.d.ts",
|
|
55
57
|
"dist/core/session-store.js",
|
|
56
58
|
"dist/core/session-store.d.ts",
|
|
57
59
|
"dist/core/vector-index.js",
|
|
@@ -102,7 +104,7 @@
|
|
|
102
104
|
"LICENSE"
|
|
103
105
|
],
|
|
104
106
|
"scripts": {
|
|
105
|
-
"build": "tsc; s=$?; chmod +x dist/bin/cli.js; cp -r core/system-skills dist/core/system-skills; tsx scripts/write-dist-package-json.ts; exit $s",
|
|
107
|
+
"build": "tsc; s=$?; chmod +x dist/bin/cli.js; rm -rf dist/core/system-skills; cp -r core/system-skills dist/core/system-skills; tsx scripts/write-dist-package-json.ts; exit $s",
|
|
106
108
|
"prepublishOnly": "npm run build",
|
|
107
109
|
"start": "node --env-file-if-exists=.env dist/adapters/http.js",
|
|
108
110
|
"test": "vitest run"
|