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
|
@@ -17,8 +17,10 @@ How to plan:
|
|
|
17
17
|
3. 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.
|
|
18
18
|
4. 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.
|
|
19
19
|
5. 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.
|
|
20
|
-
6.
|
|
21
|
-
7.
|
|
20
|
+
6. 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.
|
|
21
|
+
7. 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.
|
|
22
|
+
8. 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.
|
|
23
|
+
9. 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.
|
|
22
24
|
|
|
23
25
|
Then 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.`;
|
|
24
26
|
export function workflowPlannerTools(withMcp = false) {
|
|
@@ -72,6 +74,17 @@ export function workflowPlannerTools(withMcp = false) {
|
|
|
72
74
|
needsApprovals: { type: 'boolean' },
|
|
73
75
|
skill: { type: 'object', properties: { description: { type: 'string' }, body: { type: 'string' } }, required: ['description', 'body'] },
|
|
74
76
|
startMessage: { type: 'string' },
|
|
77
|
+
notify: {
|
|
78
|
+
type: 'object',
|
|
79
|
+
description: 'Scheduled workflows: whether each run\'s result goes to the person through the agent\'s messaging app, and why in one short sentence.',
|
|
80
|
+
properties: { on: { type: 'boolean' }, reason: { type: 'string' } },
|
|
81
|
+
required: ['on', 'reason'],
|
|
82
|
+
},
|
|
83
|
+
parameters: {
|
|
84
|
+
type: 'object',
|
|
85
|
+
description: 'Values each run is given, by snake_case name; use $name in startMessage and the skill. Leave out when every run is the same.',
|
|
86
|
+
additionalProperties: { type: 'object', properties: { description: { type: 'string' }, required: { type: 'boolean' } } },
|
|
87
|
+
},
|
|
75
88
|
target: { type: 'object', properties: { kind: { type: 'string', enum: ['existing', 'new'] }, agent: { type: 'string' }, reason: { type: 'string' } }, required: ['kind', 'reason'] },
|
|
76
89
|
newAgent: { type: 'object', properties: { name: { type: 'string' }, systemPrompt: { type: 'string' } }, required: ['name', 'systemPrompt'] },
|
|
77
90
|
gaps: { type: 'array', items: { type: 'string' } },
|
|
@@ -156,6 +169,9 @@ export function validateWorkflowPlan(raw) {
|
|
|
156
169
|
])].filter((spec) => !steps.some((s) => s.connection === spec && s.ability !== spec));
|
|
157
170
|
const targetRaw = (r.target ?? {});
|
|
158
171
|
const id = slugify(str(r.id, 60) ?? name);
|
|
172
|
+
const startMessage = str(r.startMessage, 1000) ?? `Run the "${name}" workflow.`;
|
|
173
|
+
const gaps = Array.isArray(r.gaps) ? r.gaps.flatMap((g) => (str(g, 400) ? [str(g, 400)] : [])).slice(0, 10) : [];
|
|
174
|
+
const planned = planParameters(r.parameters, { startMessage, skillBody, scheduled: trigger.kind === 'schedule' });
|
|
159
175
|
return {
|
|
160
176
|
name,
|
|
161
177
|
id,
|
|
@@ -167,16 +183,52 @@ export function validateWorkflowPlan(raw) {
|
|
|
167
183
|
keys: Array.isArray(r.keys) ? [...new Set(r.keys.flatMap((k) => (str(k, 80) ? [str(k, 80)] : [])))] : [],
|
|
168
184
|
needsApprovals: r.needsApprovals === true || steps.some((s) => s.kind === 'ask' || s.kind === 'approval'),
|
|
169
185
|
skill: { description: str(skillRaw.description, 400) ?? summary, body: skillBody },
|
|
170
|
-
startMessage
|
|
186
|
+
startMessage,
|
|
187
|
+
parameters: planned.parameters,
|
|
188
|
+
notify: planNotify(r.notify),
|
|
171
189
|
target: {
|
|
172
190
|
kind: targetRaw.kind === 'existing' && str(targetRaw.agent, 80) ? 'existing' : 'new',
|
|
173
191
|
...(targetRaw.kind === 'existing' && str(targetRaw.agent, 80) ? { agent: str(targetRaw.agent, 80) } : {}),
|
|
174
192
|
reason: str(targetRaw.reason, 600) ?? '',
|
|
175
193
|
},
|
|
176
194
|
newAgent: { name: slugify(str(agentRaw.name, 60) ?? id, id), systemPrompt },
|
|
177
|
-
gaps:
|
|
195
|
+
gaps: [...gaps, ...planned.notes].slice(0, 12),
|
|
178
196
|
};
|
|
179
197
|
}
|
|
198
|
+
/** A plan's notify choice; off when the planner gave none. */
|
|
199
|
+
export function planNotify(raw) {
|
|
200
|
+
const n = raw && typeof raw === 'object' ? raw : {};
|
|
201
|
+
return { on: n.on === true, reason: typeof n.reason === 'string' ? n.reason.trim().slice(0, 200) : '' };
|
|
202
|
+
}
|
|
203
|
+
/** A plan's parameters, tidied: names made snake_case (or dropped), a
|
|
204
|
+
* $name the start message or skill uses but doesn't declare added as an
|
|
205
|
+
* optional one, and, for a scheduled workflow, required ones made
|
|
206
|
+
* optional (a schedule has no one to fill them in), with a note saying
|
|
207
|
+
* so. Also used on a plan edited before Create. */
|
|
208
|
+
export function planParameters(raw, context) {
|
|
209
|
+
const parameters = {};
|
|
210
|
+
const notes = [];
|
|
211
|
+
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
212
|
+
for (const [rawName, spec] of Object.entries(raw)) {
|
|
213
|
+
const name = rawName.trim().toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^[^a-z_]+/, '').replace(/_+/g, '_').replace(/_$/, '').slice(0, 40);
|
|
214
|
+
if (!name || parameters[name])
|
|
215
|
+
continue;
|
|
216
|
+
const s = spec && typeof spec === 'object' ? spec : {};
|
|
217
|
+
parameters[name] = { description: typeof s.description === 'string' ? s.description.trim().slice(0, 300) : '', required: s.required === true };
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
for (const match of `${context.startMessage}\n${context.skillBody}`.matchAll(/\$([a-z_][a-z0-9_]*)/g)) {
|
|
221
|
+
parameters[match[1]] ??= { description: '', required: false };
|
|
222
|
+
}
|
|
223
|
+
if (context.scheduled) {
|
|
224
|
+
const required = Object.entries(parameters).filter(([, p]) => p.required);
|
|
225
|
+
for (const [, p] of required)
|
|
226
|
+
p.required = false;
|
|
227
|
+
if (required.length)
|
|
228
|
+
notes.push(`A schedule can't fill in ${required.map(([n]) => n).join(', ')}, so ${required.length > 1 ? 'they are' : 'it is'} optional; give ${required.length > 1 ? 'them' : 'it'} a value on the schedule, or start runs by hand or from an app.`);
|
|
229
|
+
}
|
|
230
|
+
return { parameters, notes };
|
|
231
|
+
}
|
|
180
232
|
/** Runs the planner on a description until it saves a plan. */
|
|
181
233
|
export async function planWorkflowWithModel(modelCall, description, tools) {
|
|
182
234
|
const messages = [{ role: 'user', content: `Plan this workflow:\n\n${description}` }];
|
|
@@ -284,7 +336,7 @@ export function workflowTemplate(plan) {
|
|
|
284
336
|
skills: [{ id: plan.id, description: plan.skill.description, body: plan.skill.body }],
|
|
285
337
|
rules: plan.rules.map((r) => ({ name: slugify(`${plan.id}-${r.tool}`, `${plan.id}-rule`), tool: r.tool, decision: r.decision })),
|
|
286
338
|
...(plan.trigger.kind === 'schedule' && plan.trigger.every
|
|
287
|
-
? { schedule: { every: plan.trigger.every, ...(plan.trigger.at ? { at: plan.trigger.at } : {}), ...(plan.trigger.weekday !== undefined ? { weekday: plan.trigger.weekday } : {}), name: plan.name, message: plan.startMessage } }
|
|
339
|
+
? { schedule: { every: plan.trigger.every, ...(plan.trigger.at ? { at: plan.trigger.at } : {}), ...(plan.trigger.weekday !== undefined ? { weekday: plan.trigger.weekday } : {}), name: plan.name, message: plan.startMessage, workflowId: plan.id, ...(plan.notify?.on ? { notify: true } : {}) } }
|
|
288
340
|
: {}),
|
|
289
341
|
needsApprovals: plan.needsApprovals,
|
|
290
342
|
},
|
|
@@ -1,2 +1,6 @@
|
|
|
1
1
|
import type { ToolDefinition } from '../agent-config.js';
|
|
2
|
-
|
|
2
|
+
/** `agentDir`: the agent's folder, where its workflows are listed
|
|
3
|
+
* (workflows.yml). Without it the agent can only schedule messages. */
|
|
4
|
+
export declare function createScheduleTools(agentName: string, options?: {
|
|
5
|
+
agentDir?: string;
|
|
6
|
+
}): ToolDefinition[];
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { cancelScheduledTask, createScheduledTask, getScheduledTask, listScheduledTasks, } from '../scheduler.js';
|
|
2
2
|
import { formatWallTime, workspaceTimeZone } from '../time-zone.js';
|
|
3
|
+
import { readWorkflows, validateParams } from '../workflows.js';
|
|
3
4
|
/** How many active (not done or cancelled) tasks one agent may create
|
|
4
5
|
* through these tools — each run is a model call, so a runaway loop of
|
|
5
6
|
* "schedule another one" stops here. The console and API aren't capped. */
|
|
@@ -16,7 +17,7 @@ function summary(t, timeZone) {
|
|
|
16
17
|
return {
|
|
17
18
|
task_id: t.task_id,
|
|
18
19
|
task_name: t.task_name,
|
|
19
|
-
message: t.message,
|
|
20
|
+
...(t.workflow_id ? { workflow_id: t.workflow_id, ...(t.params ? { params: t.params } : {}) } : { message: t.message }),
|
|
20
21
|
schedule: t.schedule,
|
|
21
22
|
status: t.status,
|
|
22
23
|
next_run_at: t.next_run_at,
|
|
@@ -25,10 +26,18 @@ function summary(t, timeZone) {
|
|
|
25
26
|
last_status: t.last_status,
|
|
26
27
|
};
|
|
27
28
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
/** `agentDir`: the agent's folder, where its workflows are listed
|
|
30
|
+
* (workflows.yml). Without it the agent can only schedule messages. */
|
|
31
|
+
export function createScheduleTools(agentName, options = {}) {
|
|
32
|
+
// Read once per run: the time zone and the workflows are in the tool
|
|
33
|
+
// description, which should stay the same from call to call.
|
|
31
34
|
const timeZone = workspaceTimeZone();
|
|
35
|
+
const workflows = options.agentDir ? readWorkflows(options.agentDir) : [];
|
|
36
|
+
const paramList = (w) => {
|
|
37
|
+
const names = Object.entries(w.parameters).map(([name, p]) => `${name}${p.required ? '' : '?'}`);
|
|
38
|
+
return names.length ? `; params: ${names.join(', ')}` : '';
|
|
39
|
+
};
|
|
40
|
+
const workflowList = workflows.map((w) => `"${w.id}" (${w.title}: ${w.description}${paramList(w)})`).join('; ');
|
|
32
41
|
async function ownTask(taskId, tool) {
|
|
33
42
|
const task = await getScheduledTask(taskId);
|
|
34
43
|
// Another agent's task reads exactly like a missing one.
|
|
@@ -38,14 +47,24 @@ export function createScheduleTools(agentName) {
|
|
|
38
47
|
}
|
|
39
48
|
const scheduleTask = {
|
|
40
49
|
name: 'system_schedule_task',
|
|
41
|
-
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.
|
|
50
|
+
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.' +
|
|
51
|
+
(workflows.length
|
|
52
|
+
? ' To run one of your workflows on a schedule, give its workflow_id instead of a message, and its params (names ending in ? are optional): each run then starts with that workflow\'s start message and steps as they are at the time, so later edits to the workflow apply. Your workflows: ' + workflowList + '.'
|
|
53
|
+
: '') +
|
|
54
|
+
' 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. Times are in this workspace\'s time zone, ' + timeZone + ', unless you give an offset, so when the user means another time zone, give its offset. 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, next_run_at and next_run_local (the workspace\'s time).',
|
|
42
55
|
input_schema: {
|
|
43
56
|
type: 'object',
|
|
44
57
|
properties: {
|
|
45
58
|
message: {
|
|
46
59
|
type: 'string',
|
|
47
|
-
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."',
|
|
60
|
+
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." Leave it out when giving workflow_id.',
|
|
48
61
|
},
|
|
62
|
+
...(workflows.length
|
|
63
|
+
? {
|
|
64
|
+
workflow_id: { type: 'string', enum: workflows.map((w) => w.id), description: 'One of your workflows, to run it on this schedule instead of a message.' },
|
|
65
|
+
params: { type: 'object', description: 'The workflow\'s parameters for every run, e.g. { "region": "US" }.', additionalProperties: { type: 'string' } },
|
|
66
|
+
}
|
|
67
|
+
: {}),
|
|
49
68
|
schedule: {
|
|
50
69
|
type: 'object',
|
|
51
70
|
description: 'Either { "run_at": "<ISO 8601 timestamp>" } 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 timestamp without an offset, like "2026-11-02T09:00", is in the workspace\'s time zone (' + timeZone + '). A repeating task keeps its first slot: started at 10:07 with "1h", it fires at 11:07, 12:07, ... For a time of day or day of week, add "at" (24-hour "HH:MM") and for weekly "weekday": "every day at 9am" is { "every": "1d", "at": "09:00" }; "every Monday at 9am" is { "every": "7d", "at": "09:00", "weekday": "monday" }. Daily and weekly tasks keep their time of day when the clocks change. Or give "start_at", a timestamp, for the first run.',
|
|
@@ -54,26 +73,42 @@ export function createScheduleTools(agentName) {
|
|
|
54
73
|
task_name: { type: 'string', description: 'Optional short label, shown when listing tasks and in the console.' },
|
|
55
74
|
notify: { type: 'boolean', description: 'Optional — also send each run\'s result through your notifier (where your approval requests go), so a person sees it.' },
|
|
56
75
|
},
|
|
57
|
-
required: ['
|
|
76
|
+
required: ['schedule'],
|
|
58
77
|
},
|
|
59
78
|
execute: async (input) => {
|
|
60
79
|
const active = (await listScheduledTasks({ agent: agentName })).filter((t) => t.status !== 'done' && t.status !== 'cancelled');
|
|
61
80
|
if (active.length >= maxActivePerAgent()) {
|
|
62
81
|
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`);
|
|
63
82
|
}
|
|
83
|
+
const workflowId = typeof input.workflow_id === 'string' && input.workflow_id ? input.workflow_id : undefined;
|
|
84
|
+
const workflow = workflowId ? workflows.find((w) => w.id === workflowId) : undefined;
|
|
85
|
+
if (workflowId && !workflow) {
|
|
86
|
+
throw new Error(`system_schedule_task: you have no workflow "${workflowId}"${workflows.length ? `; yours are ${workflows.map((w) => `"${w.id}"`).join(', ')}` : ''}`);
|
|
87
|
+
}
|
|
88
|
+
let params;
|
|
89
|
+
if (workflow) {
|
|
90
|
+
try {
|
|
91
|
+
params = validateParams(workflow, input.params);
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
throw new Error(`system_schedule_task: ${err instanceof Error ? err.message : String(err)}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
64
97
|
const task = await createScheduledTask({
|
|
65
98
|
agent: agentName,
|
|
66
|
-
message: String(input.message ?? ''),
|
|
99
|
+
message: workflowId ? undefined : String(input.message ?? ''),
|
|
100
|
+
workflow_id: workflowId,
|
|
101
|
+
...(params && Object.keys(params).length ? { params } : {}),
|
|
67
102
|
schedule: (input.schedule ?? {}),
|
|
68
103
|
task_name: typeof input.task_name === 'string' ? input.task_name : undefined,
|
|
69
104
|
notify: input.notify === true,
|
|
70
105
|
});
|
|
71
|
-
return JSON.stringify({ task_id: task.task_id, next_run_at: task.next_run_at, next_run_local: nextRunLocal(task, timeZone), schedule: task.schedule });
|
|
106
|
+
return JSON.stringify({ task_id: task.task_id, ...(workflowId ? { workflow_id: workflowId } : {}), next_run_at: task.next_run_at, next_run_local: nextRunLocal(task, timeZone), schedule: task.schedule });
|
|
72
107
|
},
|
|
73
108
|
};
|
|
74
109
|
const listTasks = {
|
|
75
110
|
name: 'system_list_scheduled_tasks',
|
|
76
|
-
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.',
|
|
111
|
+
description: 'List your own scheduled tasks (task_id, name, message or workflow_id, 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.',
|
|
77
112
|
input_schema: {
|
|
78
113
|
type: 'object',
|
|
79
114
|
properties: {
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { CallbackDelivery } from './run-callbacks.js';
|
|
2
|
+
export type WorkflowRunStatus = 'running' | 'awaiting_approval' | 'done' | 'error';
|
|
3
|
+
export interface WorkflowRun {
|
|
4
|
+
run_id: string;
|
|
5
|
+
agent: string;
|
|
6
|
+
workflow_id: string;
|
|
7
|
+
params?: Record<string, string>;
|
|
8
|
+
/** Who started it: the console's Run now, an app over the API, a
|
|
9
|
+
* schedule, or an outside service through a webhook trigger. */
|
|
10
|
+
source: 'console' | 'api' | 'schedule' | 'webhook';
|
|
11
|
+
/** The webhook trigger that started it, for source "webhook"
|
|
12
|
+
* (core/workflow-triggers.ts). */
|
|
13
|
+
trigger_id?: string;
|
|
14
|
+
/** The schedule that started it, for source "schedule". */
|
|
15
|
+
task_id?: string;
|
|
16
|
+
task_name?: string;
|
|
17
|
+
status: WorkflowRunStatus;
|
|
18
|
+
started_at: string;
|
|
19
|
+
finished_at?: string;
|
|
20
|
+
/** The message it started with: the workflow's start message and steps. */
|
|
21
|
+
message: string;
|
|
22
|
+
session_id: string;
|
|
23
|
+
/** The agent's final reply, when done. */
|
|
24
|
+
result?: string;
|
|
25
|
+
error?: string;
|
|
26
|
+
/** The approvals or questions it is waiting on. */
|
|
27
|
+
pending_ids?: string[];
|
|
28
|
+
/** Send its result through the agent's notifier when it ends. */
|
|
29
|
+
notify?: boolean;
|
|
30
|
+
/** Where its record is posted when it ends or stops for a decision
|
|
31
|
+
* (core/run-callbacks.ts). */
|
|
32
|
+
callback_url?: string;
|
|
33
|
+
/** Each callback delivery, oldest first (the latest 10). */
|
|
34
|
+
callbacks?: CallbackDelivery[];
|
|
35
|
+
}
|
|
36
|
+
export type RunOutcome = {
|
|
37
|
+
kind: 'done';
|
|
38
|
+
text: string;
|
|
39
|
+
} | {
|
|
40
|
+
kind: 'pending';
|
|
41
|
+
pendingIds: string[];
|
|
42
|
+
};
|
|
43
|
+
/** Runs the agent once: what a scheduled task's fire does. */
|
|
44
|
+
export type ExecuteRun = (agent: string, message: string, sessionId: string) => Promise<RunOutcome>;
|
|
45
|
+
export declare class RunError extends Error {
|
|
46
|
+
}
|
|
47
|
+
export interface RunStore {
|
|
48
|
+
list(): Promise<WorkflowRun[]>;
|
|
49
|
+
get(runId: string): Promise<WorkflowRun | undefined>;
|
|
50
|
+
put(run: WorkflowRun): Promise<void>;
|
|
51
|
+
remove(runId: string): Promise<void>;
|
|
52
|
+
update(runId: string, fn: (run: WorkflowRun | undefined) => WorkflowRun | undefined): Promise<WorkflowRun | undefined>;
|
|
53
|
+
}
|
|
54
|
+
/** One JSON file per run under `<dir>/runs/` — one server process. */
|
|
55
|
+
export declare class FileRunStore implements RunStore {
|
|
56
|
+
private readonly dir;
|
|
57
|
+
constructor(dir: string);
|
|
58
|
+
private file;
|
|
59
|
+
list(): Promise<WorkflowRun[]>;
|
|
60
|
+
get(runId: string): Promise<WorkflowRun | undefined>;
|
|
61
|
+
put(run: WorkflowRun): Promise<void>;
|
|
62
|
+
remove(runId: string): Promise<void>;
|
|
63
|
+
update(runId: string, fn: (run: WorkflowRun | undefined) => WorkflowRun | undefined): Promise<WorkflowRun | undefined>;
|
|
64
|
+
}
|
|
65
|
+
/** The process-wide store: `runs/` in the scheduler's folder. */
|
|
66
|
+
export declare function getRunStore(): RunStore;
|
|
67
|
+
export declare function setRunStore(next: RunStore): void;
|
|
68
|
+
/** An agent's runs, newest first; of one workflow when `workflowId` is given. */
|
|
69
|
+
export declare function listRuns(agent: string, filter?: {
|
|
70
|
+
workflowId?: string;
|
|
71
|
+
limit?: number;
|
|
72
|
+
}): Promise<WorkflowRun[]>;
|
|
73
|
+
/** One of an agent's runs; undefined for another agent's. */
|
|
74
|
+
export declare function getRun(agent: string, runId: string): Promise<WorkflowRun | undefined>;
|
|
75
|
+
export declare function isUnderWay(run: WorkflowRun): boolean;
|
|
76
|
+
export interface StartRunInput {
|
|
77
|
+
agent: string;
|
|
78
|
+
workflow_id: string;
|
|
79
|
+
params?: Record<string, string>;
|
|
80
|
+
source: WorkflowRun['source'];
|
|
81
|
+
message: string;
|
|
82
|
+
notify?: boolean;
|
|
83
|
+
callback_url?: string;
|
|
84
|
+
trigger_id?: string;
|
|
85
|
+
}
|
|
86
|
+
export declare class WorkflowRunner {
|
|
87
|
+
private readonly execute;
|
|
88
|
+
private readonly maxFinishedPerAgent;
|
|
89
|
+
private readonly onChange?;
|
|
90
|
+
private readonly inFlight;
|
|
91
|
+
/** `onChange`: called with the run each time it finishes, fails or
|
|
92
|
+
* stops for a decision (the server sends its notify and callback). */
|
|
93
|
+
constructor(options: {
|
|
94
|
+
execute: ExecuteRun;
|
|
95
|
+
maxFinishedPerAgent?: number;
|
|
96
|
+
onChange?: (run: WorkflowRun) => void | Promise<void>;
|
|
97
|
+
});
|
|
98
|
+
private changed;
|
|
99
|
+
/** Records a run and starts it; returns at once with the record. */
|
|
100
|
+
start(input: StartRunInput): Promise<WorkflowRun>;
|
|
101
|
+
/** A schedule firing one of its agent's workflows (the scheduler's fire,
|
|
102
|
+
* for a workflow task): recorded as a run of the workflow, then run the
|
|
103
|
+
* usual way. Returns the outcome for the scheduler, and throws what the
|
|
104
|
+
* run threw, so the schedule's own history records it too. */
|
|
105
|
+
runForSchedule(input: {
|
|
106
|
+
agent: string;
|
|
107
|
+
workflow_id: string;
|
|
108
|
+
params?: Record<string, string>;
|
|
109
|
+
task_id: string;
|
|
110
|
+
task_name?: string;
|
|
111
|
+
message: string;
|
|
112
|
+
session_id: string;
|
|
113
|
+
}): Promise<RunOutcome>;
|
|
114
|
+
/** Waits for the runs this process has started (tests, shutdown). */
|
|
115
|
+
idle(): Promise<void>;
|
|
116
|
+
/** How many runs are executing right now (for idle-shutdown checks). */
|
|
117
|
+
get running(): number;
|
|
118
|
+
private run;
|
|
119
|
+
private finish;
|
|
120
|
+
/** A paused run resumed after a decision (the server calls this from
|
|
121
|
+
* its resolve path, for every resumed session): settle it, or record
|
|
122
|
+
* what it's waiting on now. */
|
|
123
|
+
settleFromResume(sessionId: string, outcome: {
|
|
124
|
+
text: string;
|
|
125
|
+
pendingIds?: string[];
|
|
126
|
+
}): Promise<void>;
|
|
127
|
+
/** On startup: a run a restart cut off can't continue, so it's an
|
|
128
|
+
* error. One waiting on a decision is kept: its checkpoint survives. */
|
|
129
|
+
recover(): Promise<void>;
|
|
130
|
+
private prune;
|
|
131
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// Workflow runs: started by hand or by an app (Run now in the console, or
|
|
2
|
+
// POST /agents/:name/workflows/:id/run), or by a schedule that runs the
|
|
3
|
+
// workflow. Each is a record of its own here, so a workflow has one
|
|
4
|
+
// history of every run whatever started it, the Schedules tab only lists
|
|
5
|
+
// what is set to run, and each run can be looked up by its id afterwards
|
|
6
|
+
// (GET /agents/:name/runs/:id).
|
|
7
|
+
//
|
|
8
|
+
// A run executes the same way a scheduled task fires (supplied by the
|
|
9
|
+
// server, adapters/http.ts): an unattended agent turn whose approvals and
|
|
10
|
+
// questions go to the agent's messaging app and wait durably. A run that
|
|
11
|
+
// pauses there is settled by settleFromResume once someone decides.
|
|
12
|
+
//
|
|
13
|
+
// Stored like scheduled tasks: one JSON file per run under
|
|
14
|
+
// `<LOOPENGINE_SCHEDULER_DIR or .scheduler>/runs/`. Each agent keeps its
|
|
15
|
+
// latest MAX_FINISHED_PER_AGENT finished runs; older ones are removed as
|
|
16
|
+
// new runs start.
|
|
17
|
+
import { randomUUID } from 'node:crypto';
|
|
18
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { KeyedMutex } from './session-store.js';
|
|
21
|
+
const MAX_FINISHED_PER_AGENT = 500;
|
|
22
|
+
const SESSION_PREFIX = 'run-';
|
|
23
|
+
/** A scheduled task's sessions (core/scheduler.ts). */
|
|
24
|
+
const SCHEDULE_SESSION_PREFIX = 'sched-';
|
|
25
|
+
export class RunError extends Error {
|
|
26
|
+
}
|
|
27
|
+
const fileMutex = new KeyedMutex();
|
|
28
|
+
/** One JSON file per run under `<dir>/runs/` — one server process. */
|
|
29
|
+
export class FileRunStore {
|
|
30
|
+
dir;
|
|
31
|
+
constructor(dir) {
|
|
32
|
+
this.dir = path.join(dir, 'runs');
|
|
33
|
+
}
|
|
34
|
+
file(runId) {
|
|
35
|
+
if (!/^[A-Za-z0-9_-]+$/.test(runId))
|
|
36
|
+
throw new RunError(`invalid run id "${runId}"`);
|
|
37
|
+
return path.join(this.dir, `${runId}.json`);
|
|
38
|
+
}
|
|
39
|
+
async list() {
|
|
40
|
+
if (!existsSync(this.dir))
|
|
41
|
+
return [];
|
|
42
|
+
const runs = [];
|
|
43
|
+
for (const name of readdirSync(this.dir)) {
|
|
44
|
+
if (!name.endsWith('.json'))
|
|
45
|
+
continue;
|
|
46
|
+
try {
|
|
47
|
+
runs.push(JSON.parse(readFileSync(path.join(this.dir, name), 'utf8')));
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// A file being written, or not a run: skip it.
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return runs.sort((a, b) => b.started_at.localeCompare(a.started_at));
|
|
54
|
+
}
|
|
55
|
+
async get(runId) {
|
|
56
|
+
const file = this.file(runId);
|
|
57
|
+
return existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : undefined;
|
|
58
|
+
}
|
|
59
|
+
async put(run) {
|
|
60
|
+
mkdirSync(this.dir, { recursive: true });
|
|
61
|
+
const file = this.file(run.run_id);
|
|
62
|
+
writeFileSync(file + '.tmp', JSON.stringify(run, null, 2));
|
|
63
|
+
renameSync(file + '.tmp', file);
|
|
64
|
+
}
|
|
65
|
+
async remove(runId) {
|
|
66
|
+
rmSync(this.file(runId), { force: true });
|
|
67
|
+
}
|
|
68
|
+
async update(runId, fn) {
|
|
69
|
+
return fileMutex.run(runId, async () => {
|
|
70
|
+
const next = fn(await this.get(runId));
|
|
71
|
+
if (next)
|
|
72
|
+
await this.put(next);
|
|
73
|
+
return next;
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
let store;
|
|
78
|
+
/** The process-wide store: `runs/` in the scheduler's folder. */
|
|
79
|
+
export function getRunStore() {
|
|
80
|
+
store ??= new FileRunStore(process.env.LOOPENGINE_SCHEDULER_DIR || path.join(process.cwd(), '.scheduler'));
|
|
81
|
+
return store;
|
|
82
|
+
}
|
|
83
|
+
export function setRunStore(next) {
|
|
84
|
+
store = next;
|
|
85
|
+
}
|
|
86
|
+
// ---------- reading ----------
|
|
87
|
+
/** An agent's runs, newest first; of one workflow when `workflowId` is given. */
|
|
88
|
+
export async function listRuns(agent, filter = {}) {
|
|
89
|
+
const runs = (await getRunStore().list()).filter((r) => r.agent === agent && (!filter.workflowId || r.workflow_id === filter.workflowId));
|
|
90
|
+
return filter.limit ? runs.slice(0, filter.limit) : runs;
|
|
91
|
+
}
|
|
92
|
+
/** One of an agent's runs; undefined for another agent's. */
|
|
93
|
+
export async function getRun(agent, runId) {
|
|
94
|
+
if (!/^[A-Za-z0-9_-]+$/.test(runId))
|
|
95
|
+
return undefined;
|
|
96
|
+
const run = await getRunStore().get(runId);
|
|
97
|
+
return run && run.agent === agent ? run : undefined;
|
|
98
|
+
}
|
|
99
|
+
export function isUnderWay(run) {
|
|
100
|
+
return run.status === 'running' || run.status === 'awaiting_approval';
|
|
101
|
+
}
|
|
102
|
+
export class WorkflowRunner {
|
|
103
|
+
execute;
|
|
104
|
+
maxFinishedPerAgent;
|
|
105
|
+
onChange;
|
|
106
|
+
inFlight = new Set();
|
|
107
|
+
/** `onChange`: called with the run each time it finishes, fails or
|
|
108
|
+
* stops for a decision (the server sends its notify and callback). */
|
|
109
|
+
constructor(options) {
|
|
110
|
+
this.execute = options.execute;
|
|
111
|
+
this.maxFinishedPerAgent = options.maxFinishedPerAgent ?? MAX_FINISHED_PER_AGENT;
|
|
112
|
+
this.onChange = options.onChange;
|
|
113
|
+
}
|
|
114
|
+
changed(run) {
|
|
115
|
+
if (!run || !this.onChange)
|
|
116
|
+
return;
|
|
117
|
+
void Promise.resolve()
|
|
118
|
+
.then(() => this.onChange(run))
|
|
119
|
+
.catch((err) => console.error(`[loopengine] reporting run ${run.run_id} failed:`, err));
|
|
120
|
+
}
|
|
121
|
+
/** Records a run and starts it; returns at once with the record. */
|
|
122
|
+
async start(input) {
|
|
123
|
+
const runId = randomUUID();
|
|
124
|
+
const run = {
|
|
125
|
+
run_id: runId,
|
|
126
|
+
agent: input.agent,
|
|
127
|
+
workflow_id: input.workflow_id,
|
|
128
|
+
...(input.params && Object.keys(input.params).length ? { params: input.params } : {}),
|
|
129
|
+
source: input.source,
|
|
130
|
+
...(input.notify ? { notify: true } : {}),
|
|
131
|
+
...(input.callback_url ? { callback_url: input.callback_url } : {}),
|
|
132
|
+
...(input.trigger_id ? { trigger_id: input.trigger_id } : {}),
|
|
133
|
+
status: 'running',
|
|
134
|
+
started_at: new Date().toISOString(),
|
|
135
|
+
message: input.message,
|
|
136
|
+
session_id: `${SESSION_PREFIX}${runId.slice(0, 8)}-${Date.now().toString(36)}`,
|
|
137
|
+
};
|
|
138
|
+
await getRunStore().put(run);
|
|
139
|
+
await this.prune(input.agent);
|
|
140
|
+
const job = this.run(run).finally(() => this.inFlight.delete(job));
|
|
141
|
+
this.inFlight.add(job);
|
|
142
|
+
return run;
|
|
143
|
+
}
|
|
144
|
+
/** A schedule firing one of its agent's workflows (the scheduler's fire,
|
|
145
|
+
* for a workflow task): recorded as a run of the workflow, then run the
|
|
146
|
+
* usual way. Returns the outcome for the scheduler, and throws what the
|
|
147
|
+
* run threw, so the schedule's own history records it too. */
|
|
148
|
+
async runForSchedule(input) {
|
|
149
|
+
const run = {
|
|
150
|
+
run_id: randomUUID(),
|
|
151
|
+
agent: input.agent,
|
|
152
|
+
workflow_id: input.workflow_id,
|
|
153
|
+
...(input.params && Object.keys(input.params).length ? { params: input.params } : {}),
|
|
154
|
+
source: 'schedule',
|
|
155
|
+
task_id: input.task_id,
|
|
156
|
+
...(input.task_name ? { task_name: input.task_name } : {}),
|
|
157
|
+
status: 'running',
|
|
158
|
+
started_at: new Date().toISOString(),
|
|
159
|
+
message: input.message,
|
|
160
|
+
session_id: input.session_id,
|
|
161
|
+
};
|
|
162
|
+
await getRunStore().put(run);
|
|
163
|
+
await this.prune(input.agent);
|
|
164
|
+
try {
|
|
165
|
+
const outcome = await this.execute(run.agent, run.message, run.session_id);
|
|
166
|
+
if (outcome.kind === 'pending')
|
|
167
|
+
this.changed(await getRunStore().update(run.run_id, (r) => r && { ...r, status: 'awaiting_approval', pending_ids: outcome.pendingIds }));
|
|
168
|
+
else
|
|
169
|
+
await this.finish(run.run_id, { status: 'done', result: outcome.text });
|
|
170
|
+
return outcome;
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
await this.finish(run.run_id, { status: 'error', error: err instanceof Error ? err.message : String(err) });
|
|
174
|
+
throw err;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/** Waits for the runs this process has started (tests, shutdown). */
|
|
178
|
+
async idle() {
|
|
179
|
+
await Promise.all([...this.inFlight]);
|
|
180
|
+
}
|
|
181
|
+
/** How many runs are executing right now (for idle-shutdown checks). */
|
|
182
|
+
get running() {
|
|
183
|
+
return this.inFlight.size;
|
|
184
|
+
}
|
|
185
|
+
async run(run) {
|
|
186
|
+
try {
|
|
187
|
+
const outcome = await this.execute(run.agent, run.message, run.session_id);
|
|
188
|
+
if (outcome.kind === 'pending') {
|
|
189
|
+
this.changed(await getRunStore().update(run.run_id, (r) => r && { ...r, status: 'awaiting_approval', pending_ids: outcome.pendingIds }));
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
await this.finish(run.run_id, { status: 'done', result: outcome.text });
|
|
193
|
+
}
|
|
194
|
+
catch (err) {
|
|
195
|
+
await this.finish(run.run_id, { status: 'error', error: err instanceof Error ? err.message : String(err) });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
async finish(runId, end) {
|
|
199
|
+
this.changed(await getRunStore().update(runId, (r) => r && { ...r, ...end, pending_ids: undefined, finished_at: new Date().toISOString() }));
|
|
200
|
+
}
|
|
201
|
+
/** A paused run resumed after a decision (the server calls this from
|
|
202
|
+
* its resolve path, for every resumed session): settle it, or record
|
|
203
|
+
* what it's waiting on now. */
|
|
204
|
+
async settleFromResume(sessionId, outcome) {
|
|
205
|
+
if (!sessionId.startsWith(SESSION_PREFIX) && !sessionId.startsWith(SCHEDULE_SESSION_PREFIX))
|
|
206
|
+
return;
|
|
207
|
+
const run = (await getRunStore().list()).find((r) => r.session_id === sessionId);
|
|
208
|
+
if (!run)
|
|
209
|
+
return;
|
|
210
|
+
if (outcome.pendingIds?.length) {
|
|
211
|
+
this.changed(await getRunStore().update(run.run_id, (r) => r && { ...r, status: 'awaiting_approval', pending_ids: outcome.pendingIds }));
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
await this.finish(run.run_id, { status: 'done', result: outcome.text });
|
|
215
|
+
}
|
|
216
|
+
/** On startup: a run a restart cut off can't continue, so it's an
|
|
217
|
+
* error. One waiting on a decision is kept: its checkpoint survives. */
|
|
218
|
+
async recover() {
|
|
219
|
+
for (const run of await getRunStore().list()) {
|
|
220
|
+
if (run.status === 'running')
|
|
221
|
+
await this.finish(run.run_id, { status: 'error', error: 'The server restarted while this run was in progress.' });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
async prune(agent) {
|
|
225
|
+
const finished = (await listRuns(agent)).filter((r) => !isUnderWay(r));
|
|
226
|
+
for (const old of finished.slice(this.maxFinishedPerAgent))
|
|
227
|
+
await getRunStore().remove(old.run_id);
|
|
228
|
+
}
|
|
229
|
+
}
|