loopengine 0.2.2 → 0.3.1
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 +1 -1
- package/dist/core/run-agent.js +11 -2
- package/dist/core/run-callbacks.d.ts +39 -0
- package/dist/core/run-callbacks.js +125 -0
- package/dist/core/scheduler.d.ts +29 -1
- package/dist/core/scheduler.js +28 -4
- package/dist/core/skillgarden/loader.d.ts +5 -1
- package/dist/core/skillgarden/loader.js +10 -3
- package/dist/core/skillgarden/substitute.d.ts +15 -1
- package/dist/core/skillgarden/substitute.js +28 -6
- package/dist/core/system-agents/templates.d.ts +5 -0
- package/dist/core/system-agents/workflow-planner.d.ts +26 -1
- package/dist/core/system-agents/workflow-planner.js +57 -5
- package/dist/core/system-tools/schedule.d.ts +5 -1
- package/dist/core/system-tools/schedule.js +45 -10
- package/dist/core/workflow-runs.d.ts +131 -0
- package/dist/core/workflow-runs.js +229 -0
- package/dist/core/workflow-triggers.d.ts +130 -0
- package/dist/core/workflow-triggers.js +361 -0
- package/dist/core/workflows.d.ts +52 -23
- package/dist/core/workflows.js +218 -27
- package/dist/index.d.ts +7 -4
- package/dist/index.js +7 -4
- package/dist/web/agents-config-page.js +394 -22
- package/dist/web/skills-admin.d.ts +4 -8
- package/dist/web/skills-admin.js +8 -12
- package/dist/web/templates-admin.js +7 -2
- package/dist/web/workflow-admin.d.ts +43 -18
- package/dist/web/workflow-admin.js +117 -50
- package/dist/web/workflow-page.js +72 -0
- package/package.json +7 -1
package/README.md
CHANGED
|
@@ -121,7 +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** |
|
|
124
|
+
| **Schedules** | One of the agent's workflows (with values for its parameters), or a custom message, run on a timer — once at a set time, or every N minutes/hours/days. A workflow schedule starts each run with the workflow's start message and steps as they are then. Run now, pause, resume, cancel, see each run's result, and optionally send results through the agent's notifier. |
|
|
125
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. |
|
|
126
126
|
|
|
127
127
|
Models and Integrations in the sidebar show which model providers have a
|
package/dist/core/run-agent.js
CHANGED
|
@@ -302,7 +302,7 @@ async function buildTurnContext(config, modelCall, options) {
|
|
|
302
302
|
// Bound to this agent: it can schedule, list and cancel only its own
|
|
303
303
|
// tasks (see core/system-tools/schedule.ts). AgentConfig.selfScheduling
|
|
304
304
|
// false leaves them out.
|
|
305
|
-
const scheduleTools = config.selfScheduling === false ? [] : createScheduleTools(config.name);
|
|
305
|
+
const scheduleTools = config.selfScheduling === false ? [] : createScheduleTools(config.name, { agentDir: agentDirFor(config) });
|
|
306
306
|
const tools = dedupeToolsByName([
|
|
307
307
|
...systemTools,
|
|
308
308
|
askUserTool,
|
|
@@ -423,6 +423,11 @@ async function buildTurnContext(config, modelCall, options) {
|
|
|
423
423
|
type: 'string',
|
|
424
424
|
description: 'Optional arguments for the skill body — substituted for $ARGUMENTS/$1/$2 placeholders.',
|
|
425
425
|
},
|
|
426
|
+
params: {
|
|
427
|
+
type: 'object',
|
|
428
|
+
description: 'Optional named values for a skill that takes them, e.g. { "order_number": "4821" }: each fills that $name in the skill body.',
|
|
429
|
+
additionalProperties: { type: 'string' },
|
|
430
|
+
},
|
|
426
431
|
},
|
|
427
432
|
required: ['skill'],
|
|
428
433
|
},
|
|
@@ -618,7 +623,11 @@ async function runLoop(ctx, messages, newMessages, starterMessage) {
|
|
|
618
623
|
// scheduling — but it's still a tool_use block the model emitted,
|
|
619
624
|
// so it still needs a tool_result to answer it, same as any other.
|
|
620
625
|
if (block.name === 'Skill' && skillGarden) {
|
|
621
|
-
const
|
|
626
|
+
const rawParams = block.input?.params;
|
|
627
|
+
const params = rawParams && typeof rawParams === 'object' && !Array.isArray(rawParams)
|
|
628
|
+
? Object.fromEntries(Object.entries(rawParams).filter(([, v]) => v !== undefined && v !== null).map(([k, v]) => [k, String(v)]))
|
|
629
|
+
: undefined;
|
|
630
|
+
const body = skillGarden.invoke(block.input.skill, block.input?.args, params);
|
|
622
631
|
log({ type: 'skill:loaded', skill: block.input.skill });
|
|
623
632
|
resultBlocks.push({ type: 'tool_result', tool_use_id: block.id, content: body });
|
|
624
633
|
continue;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export declare class CallbackUrlError extends Error {
|
|
2
|
+
}
|
|
3
|
+
export type RunCallbackEvent = 'run.done' | 'run.failed' | 'run.awaiting_approval';
|
|
4
|
+
/** Whether an IP address is private, loopback, link-local or otherwise
|
|
5
|
+
* not on the public internet. */
|
|
6
|
+
export declare function isPrivateAddress(address: string): boolean;
|
|
7
|
+
/** Checks a callback URL as given; returns it normalised. Throws
|
|
8
|
+
* CallbackUrlError saying what's wrong. */
|
|
9
|
+
export declare function checkCallbackUrl(raw: unknown): string;
|
|
10
|
+
/** The workspace's callback secret: LOOPENGINE_CALLBACK_SECRET, or one
|
|
11
|
+
* made once and kept in the scheduler's folder. */
|
|
12
|
+
export declare function callbackSecret(): string;
|
|
13
|
+
/** The signature header for a body sent at `timestamp` (unix seconds). */
|
|
14
|
+
export declare function signCallback(body: string, timestamp: number, secret?: string): string;
|
|
15
|
+
export interface CallbackDelivery {
|
|
16
|
+
event: RunCallbackEvent;
|
|
17
|
+
delivered: boolean;
|
|
18
|
+
attempts: number;
|
|
19
|
+
status_code?: number;
|
|
20
|
+
error?: string;
|
|
21
|
+
at: string;
|
|
22
|
+
}
|
|
23
|
+
export interface DeliverOptions {
|
|
24
|
+
/** Waits between attempts, in ms; its length + 1 is the attempts made. */
|
|
25
|
+
backoffMs?: number[];
|
|
26
|
+
timeoutMs?: number;
|
|
27
|
+
fetch?: typeof fetch;
|
|
28
|
+
}
|
|
29
|
+
/** Posts `payload` to `url` for `event`, retrying a failed attempt (an
|
|
30
|
+
* error, or a status other than 2xx) after each wait in backoffMs. The
|
|
31
|
+
* host is looked up first, and a private address is refused (unless
|
|
32
|
+
* local callbacks are allowed), so a public name can't lead inward. */
|
|
33
|
+
export declare function deliverCallback(url: string, event: RunCallbackEvent, payload: unknown, options?: DeliverOptions): Promise<CallbackDelivery>;
|
|
34
|
+
/** What a run's callback carries: the event and the run's record, without
|
|
35
|
+
* its session id, its first message or past callback deliveries. */
|
|
36
|
+
export declare function runCallbackPayload(event: RunCallbackEvent, run: object): {
|
|
37
|
+
event: RunCallbackEvent;
|
|
38
|
+
run: Record<string, unknown>;
|
|
39
|
+
};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Callbacks for workflow runs started over the API: a run started with
|
|
2
|
+
// "callback_url" has its record posted there when it finishes, fails, or
|
|
3
|
+
// stops for an approval or a question (core/workflow-runs.ts calls
|
|
4
|
+
// deliverRunCallback for each of those).
|
|
5
|
+
//
|
|
6
|
+
// Each POST carries
|
|
7
|
+
// X-LoopEngine-Event: run.done | run.failed | run.awaiting_approval
|
|
8
|
+
// X-LoopEngine-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<body>">
|
|
9
|
+
// signed with the workspace's callback secret: LOOPENGINE_CALLBACK_SECRET,
|
|
10
|
+
// or one made once and kept in the scheduler's folder
|
|
11
|
+
// (GET /runs/callback-secret shows it). A receiver checks the signature
|
|
12
|
+
// and that t is recent.
|
|
13
|
+
//
|
|
14
|
+
// Only https URLs on public addresses are accepted, so a run can't be
|
|
15
|
+
// pointed at the workspace's own network. LOOPENGINE_ALLOW_LOCAL_CALLBACKS=1
|
|
16
|
+
// also allows http and local addresses, for development.
|
|
17
|
+
import { createHmac, randomBytes } from 'node:crypto';
|
|
18
|
+
import { lookup } from 'node:dns/promises';
|
|
19
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { isIP } from 'node:net';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
export class CallbackUrlError extends Error {
|
|
23
|
+
}
|
|
24
|
+
function allowLocal() {
|
|
25
|
+
return process.env.LOOPENGINE_ALLOW_LOCAL_CALLBACKS === '1';
|
|
26
|
+
}
|
|
27
|
+
/** Whether an IP address is private, loopback, link-local or otherwise
|
|
28
|
+
* not on the public internet. */
|
|
29
|
+
export function isPrivateAddress(address) {
|
|
30
|
+
const ip = address.toLowerCase().replace(/^\[|\]$/g, '');
|
|
31
|
+
const v4 = ip.startsWith('::ffff:') ? ip.slice(7) : ip;
|
|
32
|
+
if (isIP(v4) === 4) {
|
|
33
|
+
const [a, b] = v4.split('.').map(Number);
|
|
34
|
+
return a === 0 || a === 10 || a === 127 || (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254) ||
|
|
35
|
+
(a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || a >= 224;
|
|
36
|
+
}
|
|
37
|
+
if (isIP(ip) === 6) {
|
|
38
|
+
return ip === '::' || ip === '::1' || /^f[cd]/.test(ip) || /^fe[89ab]/.test(ip) || ip.startsWith('ff');
|
|
39
|
+
}
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
/** Checks a callback URL as given; returns it normalised. Throws
|
|
43
|
+
* CallbackUrlError saying what's wrong. */
|
|
44
|
+
export function checkCallbackUrl(raw) {
|
|
45
|
+
if (typeof raw !== 'string' || !raw.trim())
|
|
46
|
+
throw new CallbackUrlError('callback_url must be a URL, e.g. "https://example.com/hooks/loopengine"');
|
|
47
|
+
let url;
|
|
48
|
+
try {
|
|
49
|
+
url = new URL(raw.trim());
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
throw new CallbackUrlError(`callback_url "${raw}" isn't a URL`);
|
|
53
|
+
}
|
|
54
|
+
const host = url.hostname.toLowerCase();
|
|
55
|
+
const local = host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.internal') || host.endsWith('.local') || isPrivateAddress(host);
|
|
56
|
+
if (url.protocol !== 'https:' && !(allowLocal() && url.protocol === 'http:'))
|
|
57
|
+
throw new CallbackUrlError('callback_url must use https');
|
|
58
|
+
if (local && !allowLocal())
|
|
59
|
+
throw new CallbackUrlError('callback_url must be a public address, not a local or private one');
|
|
60
|
+
if (url.username || url.password)
|
|
61
|
+
throw new CallbackUrlError('callback_url must not carry a username or password; check the signature instead');
|
|
62
|
+
return url.toString();
|
|
63
|
+
}
|
|
64
|
+
/** The workspace's callback secret: LOOPENGINE_CALLBACK_SECRET, or one
|
|
65
|
+
* made once and kept in the scheduler's folder. */
|
|
66
|
+
export function callbackSecret() {
|
|
67
|
+
if (process.env.LOOPENGINE_CALLBACK_SECRET)
|
|
68
|
+
return process.env.LOOPENGINE_CALLBACK_SECRET;
|
|
69
|
+
const dir = process.env.LOOPENGINE_SCHEDULER_DIR || path.join(process.cwd(), '.scheduler');
|
|
70
|
+
const file = path.join(dir, 'callback-secret');
|
|
71
|
+
if (existsSync(file))
|
|
72
|
+
return readFileSync(file, 'utf8').trim();
|
|
73
|
+
mkdirSync(dir, { recursive: true });
|
|
74
|
+
const secret = randomBytes(32).toString('hex');
|
|
75
|
+
writeFileSync(file, secret + '\n', { mode: 0o600 });
|
|
76
|
+
return secret;
|
|
77
|
+
}
|
|
78
|
+
/** The signature header for a body sent at `timestamp` (unix seconds). */
|
|
79
|
+
export function signCallback(body, timestamp, secret = callbackSecret()) {
|
|
80
|
+
return `t=${timestamp},v1=${createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex')}`;
|
|
81
|
+
}
|
|
82
|
+
/** Posts `payload` to `url` for `event`, retrying a failed attempt (an
|
|
83
|
+
* error, or a status other than 2xx) after each wait in backoffMs. The
|
|
84
|
+
* host is looked up first, and a private address is refused (unless
|
|
85
|
+
* local callbacks are allowed), so a public name can't lead inward. */
|
|
86
|
+
export async function deliverCallback(url, event, payload, options = {}) {
|
|
87
|
+
const backoff = options.backoffMs ?? [2_000, 10_000, 30_000];
|
|
88
|
+
const doFetch = options.fetch ?? fetch;
|
|
89
|
+
const body = JSON.stringify(payload);
|
|
90
|
+
let last = { event, delivered: false, attempts: 0, at: new Date().toISOString() };
|
|
91
|
+
for (let attempt = 0; attempt <= backoff.length; attempt++) {
|
|
92
|
+
if (attempt > 0)
|
|
93
|
+
await new Promise((r) => setTimeout(r, backoff[attempt - 1]));
|
|
94
|
+
const at = new Date().toISOString();
|
|
95
|
+
try {
|
|
96
|
+
if (!allowLocal()) {
|
|
97
|
+
const addresses = await lookup(new URL(url).hostname, { all: true });
|
|
98
|
+
if (addresses.some((a) => isPrivateAddress(a.address))) {
|
|
99
|
+
return { event, delivered: false, attempts: attempt + 1, error: 'The callback address leads to a private network.', at };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
103
|
+
const response = await doFetch(url, {
|
|
104
|
+
method: 'POST',
|
|
105
|
+
headers: { 'content-type': 'application/json', 'x-loopengine-event': event, 'x-loopengine-signature': signCallback(body, timestamp), 'user-agent': 'LoopEngine-Callback/1' },
|
|
106
|
+
body,
|
|
107
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? 10_000),
|
|
108
|
+
redirect: 'manual',
|
|
109
|
+
});
|
|
110
|
+
last = { event, delivered: response.ok, attempts: attempt + 1, status_code: response.status, at, ...(response.ok ? {} : { error: `It answered ${response.status}.` }) };
|
|
111
|
+
if (response.ok)
|
|
112
|
+
return last;
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
last = { event, delivered: false, attempts: attempt + 1, error: err instanceof Error ? err.message : String(err), at };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return last;
|
|
119
|
+
}
|
|
120
|
+
/** What a run's callback carries: the event and the run's record, without
|
|
121
|
+
* its session id, its first message or past callback deliveries. */
|
|
122
|
+
export function runCallbackPayload(event, run) {
|
|
123
|
+
const { session_id: _session, message: _message, callbacks: _callbacks, ...rest } = run;
|
|
124
|
+
return { event, run: rest };
|
|
125
|
+
}
|
package/dist/core/scheduler.d.ts
CHANGED
|
@@ -20,7 +20,15 @@ export interface ScheduledTask {
|
|
|
20
20
|
task_id: string;
|
|
21
21
|
task_name?: string;
|
|
22
22
|
agent: string;
|
|
23
|
+
/** What the agent receives each run. Empty for a workflow task, which
|
|
24
|
+
* sends its workflow's start message instead. */
|
|
23
25
|
message: string;
|
|
26
|
+
/** Set on a workflow task: each run starts this workflow of the
|
|
27
|
+
* agent's (core/workflows.ts) as it is at run time, so editing the
|
|
28
|
+
* workflow changes what the next run does. */
|
|
29
|
+
workflow_id?: string;
|
|
30
|
+
/** The workflow's parameters for every run of this task. */
|
|
31
|
+
params?: Record<string, string>;
|
|
24
32
|
schedule: Schedule;
|
|
25
33
|
status: ScheduledTaskStatus;
|
|
26
34
|
created_at: string;
|
|
@@ -74,9 +82,20 @@ export declare class FileSchedulerStore implements SchedulerStore {
|
|
|
74
82
|
* LOOPENGINE_SCHEDULER_DIR. */
|
|
75
83
|
export declare function getSchedulerStore(): SchedulerStore;
|
|
76
84
|
export declare function setSchedulerStore(next: SchedulerStore): void;
|
|
85
|
+
/** The message a workflow run starts with, worked out at run time;
|
|
86
|
+
* undefined when the agent has no such workflow (any more). It may throw
|
|
87
|
+
* when the task's params no longer fit the workflow. Supplied by the
|
|
88
|
+
* server, which knows where agents' workflows are kept. */
|
|
89
|
+
export type ResolveWorkflowMessage = (agent: string, workflowId: string, params: Record<string, string>) => string | undefined | Promise<string | undefined>;
|
|
77
90
|
export interface CreateScheduledTaskInput {
|
|
78
91
|
agent: string;
|
|
79
|
-
message
|
|
92
|
+
/** A custom message, or… */
|
|
93
|
+
message?: string;
|
|
94
|
+
/** …one of the agent's workflows, by id. Exactly one of the two. The
|
|
95
|
+
* caller checks the workflow exists and the params fit it; the
|
|
96
|
+
* scheduler only stores them. */
|
|
97
|
+
workflow_id?: string;
|
|
98
|
+
params?: Record<string, string>;
|
|
80
99
|
/** `start_at` (with `every` only): when the first run is, and so the
|
|
81
100
|
* time of day / day of week every later run keeps, e.g. every "1d"
|
|
82
101
|
* from "2026-11-02T09:00:00+08:00" runs daily at 9:00. A start in the
|
|
@@ -105,6 +124,9 @@ export declare function getScheduledTask(taskId: string): Promise<ScheduledTask
|
|
|
105
124
|
* approval isn't retracted. */
|
|
106
125
|
export declare function cancelScheduledTask(taskId: string): Promise<ScheduledTask>;
|
|
107
126
|
export declare function pauseScheduledTask(taskId: string): Promise<ScheduledTask>;
|
|
127
|
+
/** Turns sending each run's result through the agent's notifier on or
|
|
128
|
+
* off, from the next run. */
|
|
129
|
+
export declare function setScheduledTaskNotify(taskId: string, notify: boolean): Promise<ScheduledTask>;
|
|
108
130
|
export declare function resumeScheduledTask(taskId: string, nowMs?: number): Promise<ScheduledTask>;
|
|
109
131
|
/** Fires the task on the next tick, without changing its schedule. A
|
|
110
132
|
* finished one-off task runs once more. */
|
|
@@ -135,12 +157,14 @@ export type FireTask = (task: ScheduledTask, sessionId: string) => Promise<{
|
|
|
135
157
|
}>;
|
|
136
158
|
export declare class Scheduler {
|
|
137
159
|
private readonly fire;
|
|
160
|
+
private readonly resolveWorkflowMessage?;
|
|
138
161
|
private readonly tickMs;
|
|
139
162
|
private readonly onSettled?;
|
|
140
163
|
private readonly inFlight;
|
|
141
164
|
private timer?;
|
|
142
165
|
constructor(options: {
|
|
143
166
|
fire: FireTask;
|
|
167
|
+
resolveWorkflowMessage?: ResolveWorkflowMessage;
|
|
144
168
|
tickMs?: number;
|
|
145
169
|
onSettled?: (task: ScheduledTask, run: ScheduledTaskRun) => void;
|
|
146
170
|
});
|
|
@@ -154,6 +178,10 @@ export declare class Scheduler {
|
|
|
154
178
|
* request calls it directly). */
|
|
155
179
|
tick(nowMs?: number): Promise<number>;
|
|
156
180
|
private run;
|
|
181
|
+
/** The task as it fires: a workflow task gets its workflow's start
|
|
182
|
+
* message as it is now. A workflow that's gone fails the run, rather
|
|
183
|
+
* than sending a stale or empty message. */
|
|
184
|
+
private withMessage;
|
|
157
185
|
/** A paused run resumed after a decision (adapters/http.ts calls this
|
|
158
186
|
* from the resolve path): settle it, or record the next decision it's
|
|
159
187
|
* now waiting on. */
|
package/dist/core/scheduler.js
CHANGED
|
@@ -141,8 +141,12 @@ export function setSchedulerStore(next) {
|
|
|
141
141
|
export async function createScheduledTask(input, nowMs = Date.now()) {
|
|
142
142
|
if (!input.agent)
|
|
143
143
|
throw new ScheduleError('agent is required');
|
|
144
|
-
|
|
145
|
-
|
|
144
|
+
const message = typeof input.message === 'string' ? input.message : '';
|
|
145
|
+
const workflowId = typeof input.workflow_id === 'string' ? input.workflow_id.trim() : '';
|
|
146
|
+
if (message.trim() && workflowId)
|
|
147
|
+
throw new ScheduleError('give either a message or a workflow_id, not both');
|
|
148
|
+
if (!message.trim() && !workflowId)
|
|
149
|
+
throw new ScheduleError('message or workflow_id is required');
|
|
146
150
|
let schedule;
|
|
147
151
|
let nextRunMs;
|
|
148
152
|
const timeZone = workspaceTimeZone();
|
|
@@ -197,7 +201,9 @@ export async function createScheduledTask(input, nowMs = Date.now()) {
|
|
|
197
201
|
task_id: randomUUID(),
|
|
198
202
|
task_name: input.task_name?.trim() || undefined,
|
|
199
203
|
agent: input.agent,
|
|
200
|
-
message:
|
|
204
|
+
message: workflowId ? '' : message,
|
|
205
|
+
...(workflowId ? { workflow_id: workflowId } : {}),
|
|
206
|
+
...(workflowId && input.params && Object.keys(input.params).length ? { params: input.params } : {}),
|
|
201
207
|
schedule,
|
|
202
208
|
status: 'scheduled',
|
|
203
209
|
created_at: new Date(nowMs).toISOString(),
|
|
@@ -228,6 +234,11 @@ export async function cancelScheduledTask(taskId) {
|
|
|
228
234
|
export async function pauseScheduledTask(taskId) {
|
|
229
235
|
return mustExist(await getSchedulerStore().update(taskId, (t) => (t && t.status === 'scheduled' ? { ...t, status: 'paused', next_run_at: undefined } : t)), taskId);
|
|
230
236
|
}
|
|
237
|
+
/** Turns sending each run's result through the agent's notifier on or
|
|
238
|
+
* off, from the next run. */
|
|
239
|
+
export async function setScheduledTaskNotify(taskId, notify) {
|
|
240
|
+
return mustExist(await getSchedulerStore().update(taskId, (t) => t && { ...t, notify: notify || undefined }), taskId);
|
|
241
|
+
}
|
|
231
242
|
export async function resumeScheduledTask(taskId, nowMs = Date.now()) {
|
|
232
243
|
return mustExist(await getSchedulerStore().update(taskId, (t) => {
|
|
233
244
|
if (!t || t.status !== 'paused')
|
|
@@ -343,12 +354,14 @@ async function importLegacyAbilityTasks() {
|
|
|
343
354
|
}
|
|
344
355
|
export class Scheduler {
|
|
345
356
|
fire;
|
|
357
|
+
resolveWorkflowMessage;
|
|
346
358
|
tickMs;
|
|
347
359
|
onSettled;
|
|
348
360
|
inFlight = new Set();
|
|
349
361
|
timer;
|
|
350
362
|
constructor(options) {
|
|
351
363
|
this.fire = options.fire;
|
|
364
|
+
this.resolveWorkflowMessage = options.resolveWorkflowMessage;
|
|
352
365
|
this.tickMs = options.tickMs ?? (Number(process.env.LOOPENGINE_SCHEDULER_TICK_MS) || 10_000);
|
|
353
366
|
this.onSettled = options.onSettled;
|
|
354
367
|
}
|
|
@@ -392,7 +405,7 @@ export class Scheduler {
|
|
|
392
405
|
return;
|
|
393
406
|
const sessionId = `sched-${taskId.slice(0, 8)}-${claimed.run_count + 1}-${Date.now().toString(36)}`;
|
|
394
407
|
try {
|
|
395
|
-
const outcome = await this.fire(claimed, sessionId);
|
|
408
|
+
const outcome = await this.fire(await this.withMessage(claimed), sessionId);
|
|
396
409
|
if (outcome.kind === 'pending') {
|
|
397
410
|
await getSchedulerStore().update(taskId, (t) => t && {
|
|
398
411
|
...t,
|
|
@@ -410,6 +423,17 @@ export class Scheduler {
|
|
|
410
423
|
await this.settle(taskId, ranAt, { status: 'error', error: err instanceof Error ? err.message : String(err), session_id: sessionId });
|
|
411
424
|
}
|
|
412
425
|
}
|
|
426
|
+
/** The task as it fires: a workflow task gets its workflow's start
|
|
427
|
+
* message as it is now. A workflow that's gone fails the run, rather
|
|
428
|
+
* than sending a stale or empty message. */
|
|
429
|
+
async withMessage(task) {
|
|
430
|
+
if (!task.workflow_id)
|
|
431
|
+
return task;
|
|
432
|
+
const message = this.resolveWorkflowMessage ? await this.resolveWorkflowMessage(task.agent, task.workflow_id, task.params ?? {}) : undefined;
|
|
433
|
+
if (!message?.trim())
|
|
434
|
+
throw new ScheduleError(`The workflow "${task.workflow_id}" is no longer on ${task.agent}; cancel this schedule or schedule another workflow.`);
|
|
435
|
+
return { ...task, message };
|
|
436
|
+
}
|
|
413
437
|
/** A paused run resumed after a decision (adapters/http.ts calls this
|
|
414
438
|
* from the resolve path): settle it, or record the next decision it's
|
|
415
439
|
* now waiting on. */
|
|
@@ -23,7 +23,11 @@ export declare class SkillGarden {
|
|
|
23
23
|
load(name: string): LoadedSkill;
|
|
24
24
|
/** Load + substitute placeholders, ready to inject into a running
|
|
25
25
|
* conversation. */
|
|
26
|
-
|
|
26
|
+
/** A skill's body for the model, with `params` filled into its
|
|
27
|
+
* `$name` placeholders (those its front matter declares under
|
|
28
|
+
* `parameters` become empty when not given) and `args` into
|
|
29
|
+
* `$ARGUMENTS`/`$1`… */
|
|
30
|
+
invoke(name: string, args?: string, params?: Record<string, string>): string;
|
|
27
31
|
/** Filters index entries down to skills eligible given files touched
|
|
28
32
|
* so far in the session. Skills with no `paths` restriction are always
|
|
29
33
|
* eligible. */
|
|
@@ -3,7 +3,7 @@ import { matchesActivationPaths } from './activation.js';
|
|
|
3
3
|
import { buildBudgetedIndex, estimateTokens } from './budget.js';
|
|
4
4
|
import { discoverSkillFiles } from './discovery.js';
|
|
5
5
|
import { parseSkillFile } from './frontmatter.js';
|
|
6
|
-
import { substituteArguments } from './substitute.js';
|
|
6
|
+
import { substituteArguments, substituteParameters } from './substitute.js';
|
|
7
7
|
/** Phase 1: scan + parse frontmatter only, budget-capped — this is what
|
|
8
8
|
* stays loaded in context at all times. Phase 2 (`load`/`invoke`) reads
|
|
9
9
|
* the full body lazily, only for a skill actually being invoked. */
|
|
@@ -52,9 +52,16 @@ export class SkillGarden {
|
|
|
52
52
|
}
|
|
53
53
|
/** Load + substitute placeholders, ready to inject into a running
|
|
54
54
|
* conversation. */
|
|
55
|
-
|
|
55
|
+
/** A skill's body for the model, with `params` filled into its
|
|
56
|
+
* `$name` placeholders (those its front matter declares under
|
|
57
|
+
* `parameters` become empty when not given) and `args` into
|
|
58
|
+
* `$ARGUMENTS`/`$1`… */
|
|
59
|
+
invoke(name, args, params) {
|
|
56
60
|
const skill = this.load(name);
|
|
57
|
-
|
|
61
|
+
const declaredSpec = skill.frontmatter.parameters;
|
|
62
|
+
const declared = declaredSpec && typeof declaredSpec === 'object' && !Array.isArray(declaredSpec) ? Object.keys(declaredSpec) : [];
|
|
63
|
+
const named = params || declared.length ? substituteParameters(skill.body, params ?? {}, { declared }) : skill.body;
|
|
64
|
+
return substituteArguments(named, args);
|
|
58
65
|
}
|
|
59
66
|
/** Filters index entries down to skills eligible given files touched
|
|
60
67
|
* so far in the session. Skills with no `paths` restriction are always
|
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
/** Replaces `$ARGUMENTS` with the raw args string, and `$1`/`$2`/... with
|
|
2
2
|
* whitespace-split positional args — the same placeholder convention
|
|
3
|
-
* skill authors already expect from Claude Code's own Skill tool.
|
|
3
|
+
* skill authors already expect from Claude Code's own Skill tool.
|
|
4
|
+
*
|
|
5
|
+
* A `$N` is filled only when that argument was given: with two args, `$1`
|
|
6
|
+
* and `$2` are, while `$3` and a dollar amount like `$50` stay as
|
|
7
|
+
* written; with none, nothing positional changes. `$$1` is a literal
|
|
8
|
+
* "$1", for a skill that takes args and mentions one. One pass, so an
|
|
9
|
+
* argument's own text is never filled in again. */
|
|
4
10
|
export declare function substituteArguments(body: string, args?: string): string;
|
|
11
|
+
/** Replaces `$name` placeholders (lowercase, e.g. `$order_number`) with
|
|
12
|
+
* named parameters. A name in `declared` that wasn't given becomes empty
|
|
13
|
+
* text; any other `$word` is left as it is, so `$ARGUMENTS`, `$1` and
|
|
14
|
+
* dollar amounts are untouched. Names it fills are added to `used`. */
|
|
15
|
+
export declare function substituteParameters(body: string, params: Record<string, string>, options?: {
|
|
16
|
+
declared?: string[];
|
|
17
|
+
used?: Set<string>;
|
|
18
|
+
}): string;
|
|
@@ -1,13 +1,35 @@
|
|
|
1
1
|
/** Replaces `$ARGUMENTS` with the raw args string, and `$1`/`$2`/... with
|
|
2
2
|
* whitespace-split positional args — the same placeholder convention
|
|
3
|
-
* skill authors already expect from Claude Code's own Skill tool.
|
|
3
|
+
* skill authors already expect from Claude Code's own Skill tool.
|
|
4
|
+
*
|
|
5
|
+
* A `$N` is filled only when that argument was given: with two args, `$1`
|
|
6
|
+
* and `$2` are, while `$3` and a dollar amount like `$50` stay as
|
|
7
|
+
* written; with none, nothing positional changes. `$$1` is a literal
|
|
8
|
+
* "$1", for a skill that takes args and mentions one. One pass, so an
|
|
9
|
+
* argument's own text is never filled in again. */
|
|
4
10
|
export function substituteArguments(body, args) {
|
|
5
11
|
const argString = args ?? '';
|
|
6
|
-
let result = body.replaceAll('$ARGUMENTS', argString);
|
|
7
12
|
const positional = argString.split(/\s+/).filter(Boolean);
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
13
|
+
return body.replace(/\$(\$(?=\d)|ARGUMENTS|\d+)/g, (match, token) => {
|
|
14
|
+
if (token === '$')
|
|
15
|
+
return '$';
|
|
16
|
+
if (token === 'ARGUMENTS')
|
|
17
|
+
return argString;
|
|
18
|
+
const index = Number(token);
|
|
19
|
+
return index >= 1 && index <= positional.length ? positional[index - 1] : match;
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
/** Replaces `$name` placeholders (lowercase, e.g. `$order_number`) with
|
|
23
|
+
* named parameters. A name in `declared` that wasn't given becomes empty
|
|
24
|
+
* text; any other `$word` is left as it is, so `$ARGUMENTS`, `$1` and
|
|
25
|
+
* dollar amounts are untouched. Names it fills are added to `used`. */
|
|
26
|
+
export function substituteParameters(body, params, options = {}) {
|
|
27
|
+
const declared = new Set(options.declared ?? []);
|
|
28
|
+
return body.replace(/\$([a-z_][a-z0-9_]*)/g, (match, name) => {
|
|
29
|
+
if (Object.prototype.hasOwnProperty.call(params, name)) {
|
|
30
|
+
options.used?.add(name);
|
|
31
|
+
return params[name];
|
|
32
|
+
}
|
|
33
|
+
return declared.has(name) ? '' : match;
|
|
11
34
|
});
|
|
12
|
-
return result;
|
|
13
35
|
}
|
|
@@ -21,6 +21,11 @@ export interface TemplateSchedule {
|
|
|
21
21
|
name: string;
|
|
22
22
|
/** What the agent receives on each run. */
|
|
23
23
|
message: string;
|
|
24
|
+
/** Set when the template comes from a workflow: the schedule runs that
|
|
25
|
+
* workflow (its start message at run time) rather than `message`. */
|
|
26
|
+
workflowId?: string;
|
|
27
|
+
/** Send each run's result through the agent's notifier. */
|
|
28
|
+
notify?: boolean;
|
|
24
29
|
}
|
|
25
30
|
/** An app (core/mcp-apps.ts) a template works through, and the server
|
|
26
31
|
* tools it relies on: switched on when the agent is created, if the app is
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ToolSchema } from '#core/agent-config.js';
|
|
2
|
+
import type { WorkflowParameter } from '#core/workflows.js';
|
|
2
3
|
import type { ModelCall } from '#core/run-agent.js';
|
|
3
4
|
import type { StarterTemplate, TemplateApp } from './templates.js';
|
|
4
5
|
export type Decision = 'allow' | 'ask' | 'deny';
|
|
@@ -76,6 +77,15 @@ export interface WorkflowPlan {
|
|
|
76
77
|
};
|
|
77
78
|
/** What the agent receives when the workflow starts. */
|
|
78
79
|
startMessage: string;
|
|
80
|
+
/** Values a run is given, filled into $name in the start message and
|
|
81
|
+
* steps (core/workflows.ts). Empty when every run is the same. */
|
|
82
|
+
parameters: Record<string, WorkflowParameter>;
|
|
83
|
+
/** Whether each scheduled run's result is sent to the person through
|
|
84
|
+
* the agent's messaging app, and why (shown on the review screen). */
|
|
85
|
+
notify: {
|
|
86
|
+
on: boolean;
|
|
87
|
+
reason: string;
|
|
88
|
+
};
|
|
79
89
|
target: {
|
|
80
90
|
kind: 'existing' | 'new';
|
|
81
91
|
agent?: string;
|
|
@@ -115,13 +125,28 @@ export interface WorkflowPlannerTools {
|
|
|
115
125
|
/** A server's tools, when it can be listed without a key (or with one already set). */
|
|
116
126
|
mcpServerTools?(id: string): Promise<unknown>;
|
|
117
127
|
}
|
|
118
|
-
export declare const WORKFLOW_PLANNER_PROMPT = "You turn a workflow a person describes into a plan for a LoopEngine agent.\n\nLoopEngine agents call tools. Tools come from abilities (installable packages: public ones on npm, or the workspace's own private ones) or are built in. Each tool has a permission: allow (runs on its own), ask (waits for a person to approve, edit or deny it) or deny. When nobody is in Chat, questions and approvals go to the agent's messaging app (Slack, Telegram, Lark or email) and the run waits, for days if needed, then carries on.\n\nHow to plan:\n1. Look before you plan: list_agents (what exists, and what each agent's tools and rules are), list_workspace_abilities, search_public_abilities for each kind of step, ability_details for any ability you might use (its exact tool names and the settings it needs), and list_connections.\n2. Split the workflow into steps, in order. For each step:\n - something to fetch or do: kind \"tool\", with the exact tool name and where it comes from. Pick the source in this order:\n a. A tool the target agent already has (from list_agents): use it, nothing to install.\n b. An ability: a workspace ability (list_workspace_abilities) or a public one (search_public_abilities, e.g. \"firecrawl\" or \"web search\" for searching and reading the web). Set \"ability\" to its install spec (the npm name, or a workspace ability's spec) and use the exact tool name from ability_details.\n c. Only when no ability can do it, and search_mcp_servers is available: an app's MCP server. Search for the app (e.g. \"linear\", \"stripe\"), pick the app's own server (official), else a saved or verified one, then call mcp_server_tools. Set \"mcp\" to the server's id, and \"tool\" to its exact tool name when mcp_server_tools lists them (leave \"tool\" empty when it needs a key first, and say in \"detail\" what the step does). It's set up as a private ability when the workflow is created, which picks its tools and their permissions, so add no rules for MCP tools.\n - the person chooses or answers (\"ask me which\", \"let me pick\"): kind \"ask\", tool \"system_ask_user\" (built in, no ability). Say what options are offered. It reaches them through the agent's messaging app by itself, so don't add a separate step to post to Slack or another chat first, and don't install a chat ability just to ask.\n - the person reviews or approves before something happens (\"let me review it\", \"before it's sent\"): kind \"approval\", on the tool that does that thing; its rule becomes \"ask\".\n - the agent's own work (writing, summarising, choosing): kind \"other\".\n - nothing found can do it: set \"missing\" to what's needed, and add it to gaps.\n3. Rules: every tool the workflow uses gets one. Reading and searching: allow. Anything a person should see first (sending, publishing, paying, creating something others see): ask. Never set deny unless asked.\n4. Skill: the steps in order, as numbered instructions the agent follows, naming the tools and saying exactly when to stop and ask. Write it so the agent keeps going after each answer or decision.\n5. Trigger: a schedule (\"every Monday at 9\" is every \"7d\", at \"09:00\", weekday 1) or on_request (started from Chat or a message). startMessage is what the agent receives when it starts.\n6. Target: an existing agent only when it already has (or can install) the tools, none of the workflow's rules would change a decision it already has, and the same people would approve. Otherwise a new agent, with a short lowercase-hyphenated name and a system prompt saying who it is and what it's for. Give the reason either way.\
|
|
128
|
+
export declare const WORKFLOW_PLANNER_PROMPT = "You turn a workflow a person describes into a plan for a LoopEngine agent.\n\nLoopEngine agents call tools. Tools come from abilities (installable packages: public ones on npm, or the workspace's own private ones) or are built in. Each tool has a permission: allow (runs on its own), ask (waits for a person to approve, edit or deny it) or deny. When nobody is in Chat, questions and approvals go to the agent's messaging app (Slack, Telegram, Lark or email) and the run waits, for days if needed, then carries on.\n\nHow to plan:\n1. Look before you plan: list_agents (what exists, and what each agent's tools and rules are), list_workspace_abilities, search_public_abilities for each kind of step, ability_details for any ability you might use (its exact tool names and the settings it needs), and list_connections.\n2. Split the workflow into steps, in order. For each step:\n - something to fetch or do: kind \"tool\", with the exact tool name and where it comes from. Pick the source in this order:\n a. A tool the target agent already has (from list_agents): use it, nothing to install.\n b. An ability: a workspace ability (list_workspace_abilities) or a public one (search_public_abilities, e.g. \"firecrawl\" or \"web search\" for searching and reading the web). Set \"ability\" to its install spec (the npm name, or a workspace ability's spec) and use the exact tool name from ability_details.\n c. Only when no ability can do it, and search_mcp_servers is available: an app's MCP server. Search for the app (e.g. \"linear\", \"stripe\"), pick the app's own server (official), else a saved or verified one, then call mcp_server_tools. Set \"mcp\" to the server's id, and \"tool\" to its exact tool name when mcp_server_tools lists them (leave \"tool\" empty when it needs a key first, and say in \"detail\" what the step does). It's set up as a private ability when the workflow is created, which picks its tools and their permissions, so add no rules for MCP tools.\n - the person chooses or answers (\"ask me which\", \"let me pick\"): kind \"ask\", tool \"system_ask_user\" (built in, no ability). Say what options are offered. It reaches them through the agent's messaging app by itself, so don't add a separate step to post to Slack or another chat first, and don't install a chat ability just to ask.\n - the person reviews or approves before something happens (\"let me review it\", \"before it's sent\"): kind \"approval\", on the tool that does that thing; its rule becomes \"ask\".\n - the agent's own work (writing, summarising, choosing): kind \"other\".\n - nothing found can do it: set \"missing\" to what's needed, and add it to gaps.\n3. Rules: every tool the workflow uses gets one. Reading and searching: allow. Anything a person should see first (sending, publishing, paying, creating something others see): ask. Never set deny unless asked.\n4. Skill: the steps in order, as numbered instructions the agent follows, naming the tools and saying exactly when to stop and ask. Write it so the agent keeps going after each answer or decision.\n5. Trigger: a schedule (\"every Monday at 9\" is every \"7d\", at \"09:00\", weekday 1) or on_request (started from Chat or a message). startMessage is what the agent receives when it starts.\n6. Notify (scheduled workflows): whether each run's result is sent to the person through the agent's messaging app. true when the result is for them and no step already delivers it (\"tell me\", \"send me\", \"let me know\", a summary or findings that no step posts or emails). false when a step already sends it (posting to a channel, emailing a report): it would arrive twice. false when the work itself is the outcome (answering emails, filing tasks), where a message every run is noise; questions and approvals reach them either way. Say why in one short sentence.\n7. Parameters: if the job is about something that changes from run to run (an order, a ticket, an alert, a customer, a date range), declare it as a parameter: a short snake_case name, a one-line description, and required when a run makes no sense without it. Write $name wherever its value goes, in startMessage and in the skill (\"Check the refund request for order $order_number.\"). Apps and people fill them in when they start a run. A scheduled job that finds its own work (\"check the inbox every 10 minutes\", \"last week's sales\") needs none. Don't make fixed settings, such as the repository or the channel, parameters: write them into the skill.\n8. Target: an existing agent only when it already has (or can install) the tools, none of the workflow's rules would change a decision it already has, and the same people would approve. Otherwise a new agent, with a short lowercase-hyphenated name and a system prompt saying who it is and what it's for. Give the reason either way.\n9. keys: the setting names its abilities need that list_connections or the agents don't show as set (from ability_details settings). Not for MCP servers: their keys are asked for on the plan.\n\nThen call save_plan once, with the whole plan. Don't ask the person questions: if something is unclear, plan the most reasonable version and note the assumption in gaps.";
|
|
119
129
|
export declare function workflowPlannerTools(withMcp?: boolean): ToolSchema[];
|
|
120
130
|
/** lowercase-hyphenated, 2–40 characters. */
|
|
121
131
|
export declare function slugify(value: string, fallback?: string): string;
|
|
122
132
|
/** A plan from the model's save_plan input, or undefined when it lacks a
|
|
123
133
|
* name, steps, a skill or a new-agent prompt. Fills in what can be derived. */
|
|
124
134
|
export declare function validateWorkflowPlan(raw: unknown): WorkflowPlan | undefined;
|
|
135
|
+
/** A plan's notify choice; off when the planner gave none. */
|
|
136
|
+
export declare function planNotify(raw: unknown): WorkflowPlan['notify'];
|
|
137
|
+
/** A plan's parameters, tidied: names made snake_case (or dropped), a
|
|
138
|
+
* $name the start message or skill uses but doesn't declare added as an
|
|
139
|
+
* optional one, and, for a scheduled workflow, required ones made
|
|
140
|
+
* optional (a schedule has no one to fill them in), with a note saying
|
|
141
|
+
* so. Also used on a plan edited before Create. */
|
|
142
|
+
export declare function planParameters(raw: unknown, context: {
|
|
143
|
+
startMessage: string;
|
|
144
|
+
skillBody: string;
|
|
145
|
+
scheduled: boolean;
|
|
146
|
+
}): {
|
|
147
|
+
parameters: Record<string, WorkflowParameter>;
|
|
148
|
+
notes: string[];
|
|
149
|
+
};
|
|
125
150
|
export interface WorkflowPlanResult {
|
|
126
151
|
plan?: WorkflowPlan;
|
|
127
152
|
/** The planner's last words when it didn't save a plan. */
|