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
|
@@ -15,6 +15,11 @@ import { ActauthRuleExistsError, addActauthRule, setDefaultDecision } from './ac
|
|
|
15
15
|
import { setAgentEnvVar } from './env-admin.js';
|
|
16
16
|
import { freeAgentName } from './interview-admin.js';
|
|
17
17
|
import { writeSkill } from './skills-admin.js';
|
|
18
|
+
/** What a template's schedule runs (its workflow, when it comes from one,
|
|
19
|
+
* or its message), and whether it sends each result. */
|
|
20
|
+
function scheduleRuns(schedule) {
|
|
21
|
+
return { ...(schedule.workflowId ? { workflow_id: schedule.workflowId } : { message: schedule.message }), ...(schedule.notify ? { notify: true } : {}) };
|
|
22
|
+
}
|
|
18
23
|
export class TemplateError extends Error {
|
|
19
24
|
}
|
|
20
25
|
/** A failed install, in a line the checklist can show: npm's own output
|
|
@@ -201,7 +206,7 @@ export async function createFromTemplateObject(template, deps) {
|
|
|
201
206
|
const startAt = templateScheduleStart(setup.schedule);
|
|
202
207
|
const task = await createScheduledTask({
|
|
203
208
|
agent: name,
|
|
204
|
-
|
|
209
|
+
...scheduleRuns(setup.schedule),
|
|
205
210
|
schedule: { every: setup.schedule.every, ...(startAt ? { start_at: startAt } : {}) },
|
|
206
211
|
task_name: setup.schedule.name,
|
|
207
212
|
});
|
|
@@ -246,7 +251,7 @@ async function createFromTemplateLegacy(id, deps) {
|
|
|
246
251
|
if (setup.schedule) {
|
|
247
252
|
try {
|
|
248
253
|
const startAt = templateScheduleStart(setup.schedule);
|
|
249
|
-
const task = await createScheduledTask({ agent: name,
|
|
254
|
+
const task = await createScheduledTask({ agent: name, ...scheduleRuns(setup.schedule), schedule: { every: setup.schedule.every, ...(startAt ? { start_at: startAt } : {}) }, task_name: setup.schedule.name });
|
|
250
255
|
await pauseScheduledTask(task.task_id);
|
|
251
256
|
schedule = { description: describeTemplateSchedule(setup.schedule), timeZone: workspaceTimeZone(), ...(startAt ? { next_run_at: startAt } : {}) };
|
|
252
257
|
}
|
|
@@ -2,7 +2,8 @@ import { type CreatedTemplateAgent, type TemplateChecklistItem, type TemplateCre
|
|
|
2
2
|
import { type WorkflowPlan, type WorkflowPlannerTools } from '../core/system-agents/workflow-planner.js';
|
|
3
3
|
import { type GeneratedMcpAbility, type McpAbilityRequest } from '../core/mcp-ability-generator.js';
|
|
4
4
|
import type { McpDirectoryServer } from '../core/mcp-directory.js';
|
|
5
|
-
import { type
|
|
5
|
+
import { type Workflow } from '../core/workflows.js';
|
|
6
|
+
import { type WorkflowRun } from '../core/workflow-runs.js';
|
|
6
7
|
import type { ModelCall } from '../core/run-agent.js';
|
|
7
8
|
export declare class WorkflowError extends Error {
|
|
8
9
|
}
|
|
@@ -147,24 +148,48 @@ export declare function createWorkflow(input: {
|
|
|
147
148
|
description?: unknown;
|
|
148
149
|
mcpKeys?: unknown;
|
|
149
150
|
}, deps: WorkflowCreateDeps): Promise<CreatedWorkflow>;
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
151
|
+
type WorkflowScheduleView = {
|
|
152
|
+
taskId: string;
|
|
153
|
+
status: string;
|
|
154
|
+
repeating: boolean;
|
|
155
|
+
params?: Record<string, string>;
|
|
156
|
+
nextRunAt?: string;
|
|
157
|
+
lastRunAt?: string;
|
|
158
|
+
lastStatus?: string;
|
|
159
|
+
waiting: boolean;
|
|
160
|
+
};
|
|
161
|
+
export interface AgentWorkflowView extends Workflow {
|
|
162
|
+
/** Its main schedule: a repeating one if it has one. */
|
|
163
|
+
schedule?: WorkflowScheduleView;
|
|
164
|
+
/** Every live task that runs it, repeating ones first. */
|
|
165
|
+
schedules: WorkflowScheduleView[];
|
|
166
|
+
/** Its latest runs started by hand or by an app, newest first. */
|
|
167
|
+
recentRuns: WorkflowRun[];
|
|
159
168
|
}
|
|
160
|
-
/**
|
|
169
|
+
/** Moves an agent's workflows.yml, from before workflows were skills, into
|
|
170
|
+
* its skills, and points the schedules it linked by name at the workflows
|
|
171
|
+
* they run. Safe to call any time: it does nothing once the file is gone. */
|
|
172
|
+
export declare function migrateAgentWorkflows(agentName: string, agentDir: string): Promise<void>;
|
|
173
|
+
/** An agent's workflows, each with the state of the tasks that run it. */
|
|
161
174
|
export declare function listAgentWorkflows(agentName: string, agentDir: string): Promise<AgentWorkflowView[]>;
|
|
162
|
-
/**
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
|
|
166
|
-
|
|
175
|
+
/** Gets a run of a workflow ready to start, with these parameters: checks
|
|
176
|
+
* them, and that a run with the same parameters isn't already under way
|
|
177
|
+
* (runs with other parameters go ahead), and works out the message it
|
|
178
|
+
* starts with — the workflow's start message and steps as they are now.
|
|
179
|
+
* The server starts it (core/workflow-runs.ts). */
|
|
180
|
+
export declare function prepareWorkflowRun(agentName: string, agentDir: string, id: string, rawParams?: unknown): Promise<{
|
|
181
|
+
workflow: Workflow;
|
|
182
|
+
params: Record<string, string>;
|
|
183
|
+
message: string;
|
|
184
|
+
}>;
|
|
185
|
+
/** Changes how a workflow's runs start, from the console's workflow
|
|
186
|
+
* editor: its title, start message, trigger and parameters. Its steps
|
|
187
|
+
* (the skill body) and the skill's other front matter are kept. */
|
|
188
|
+
export declare function updateWorkflow(agentDir: string, id: string, input: Record<string, unknown>): Workflow;
|
|
189
|
+
/** Forgets a workflow: its skill stays, as a plain skill, and the
|
|
190
|
+
* schedules that ran it are cancelled. Returns how many were. */
|
|
191
|
+
export declare function forgetWorkflow(agentName: string, agentDir: string, id: string): Promise<{
|
|
192
|
+
cancelled: number;
|
|
167
193
|
}>;
|
|
168
|
-
/** Forgets a workflow (its skill, schedule and rules stay). */
|
|
169
|
-
export declare function forgetWorkflow(agentDir: string, id: string): void;
|
|
170
194
|
export declare function agentDirIn(projectDir: string, name: string): string;
|
|
195
|
+
export {};
|
|
@@ -10,10 +10,11 @@ import { addActauthRule, readActauthConfig } from './actauth-admin.js';
|
|
|
10
10
|
import { writeSkill } from './skills-admin.js';
|
|
11
11
|
import { createFromTemplateObject, installFailureReason, templateChecklist } from './templates-admin.js';
|
|
12
12
|
import { templateScheduleStart } from '../core/system-agents/templates.js';
|
|
13
|
-
import { planWorkflowWithModel, slugify, workflowTemplate } from '../core/system-agents/workflow-planner.js';
|
|
13
|
+
import { planParameters, planWorkflowWithModel, slugify, workflowTemplate } from '../core/system-agents/workflow-planner.js';
|
|
14
14
|
import { mcpAbilityNames } from '../core/mcp-ability-generator.js';
|
|
15
|
-
import { readWorkflows,
|
|
16
|
-
import {
|
|
15
|
+
import { clearWorkflowFields, migrateWorkflowsFile, readWorkflow, readWorkflows, setWorkflowFields, validateParams, workflowRunMessage } from '../core/workflows.js';
|
|
16
|
+
import { isUnderWay, listRuns } from '../core/workflow-runs.js';
|
|
17
|
+
import { cancelScheduledTask, createScheduledTask, getSchedulerStore, listScheduledTasks, pauseScheduledTask } from '../core/scheduler.js';
|
|
17
18
|
import { workspaceTimeZone } from '../core/time-zone.js';
|
|
18
19
|
export class WorkflowError extends Error {
|
|
19
20
|
}
|
|
@@ -304,18 +305,11 @@ export async function createWorkflow(input, deps) {
|
|
|
304
305
|
if (!plan || typeof plan !== 'object' || !Array.isArray(plan.steps) || !plan.skill || !plan.newAgent)
|
|
305
306
|
throw new WorkflowError('Plan the workflow first.');
|
|
306
307
|
const target = (input.target ?? plan.target);
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
trigger: { kind: plan.trigger.kind, description: plan.trigger.description },
|
|
313
|
-
steps: plan.steps,
|
|
314
|
-
startMessage: plan.startMessage,
|
|
315
|
-
...(scheduleName ? { scheduleName } : {}),
|
|
316
|
-
...(description ? { description } : {}),
|
|
317
|
-
createdAt: new Date().toISOString(),
|
|
318
|
-
});
|
|
308
|
+
// The plan's skill becomes a workflow: how a run starts, in its front
|
|
309
|
+
// matter. Its parameters are tidied again, as the plan may have been
|
|
310
|
+
// edited on the review screen.
|
|
311
|
+
const { parameters } = planParameters(plan.parameters, { startMessage: plan.startMessage, skillBody: plan.skill.body, scheduled: plan.trigger.kind === 'schedule' });
|
|
312
|
+
const markWorkflow = (agentDir) => setWorkflowFields(agentDir, plan.id, { title: plan.name, startMessage: plan.startMessage, trigger: plan.trigger.description, parameters });
|
|
319
313
|
const agents = await deps.agentInfo();
|
|
320
314
|
resolveConnections(plan, agents);
|
|
321
315
|
const keys = input.mcpKeys && typeof input.mcpKeys === 'object' ? Object.fromEntries(Object.entries(input.mcpKeys).flatMap(([k, v]) => (typeof v === 'string' && v.trim() ? [[k, v.trim()]] : []))) : {};
|
|
@@ -326,7 +320,7 @@ export async function createWorkflow(input, deps) {
|
|
|
326
320
|
// Read on its next run: the agent needs no reloading for it.
|
|
327
321
|
const connected = copyConnectedTools(plan, created.name, agents, deps.agentDir);
|
|
328
322
|
const loadError = created.loadError;
|
|
329
|
-
|
|
323
|
+
markWorkflow(deps.agentDir(created.name));
|
|
330
324
|
return {
|
|
331
325
|
agent: created.name,
|
|
332
326
|
newAgent: true,
|
|
@@ -426,7 +420,8 @@ export async function createWorkflow(input, deps) {
|
|
|
426
420
|
const startAt = templateScheduleStart(template.setup.schedule);
|
|
427
421
|
const task = await createScheduledTask({
|
|
428
422
|
agent: agent.name,
|
|
429
|
-
|
|
423
|
+
workflow_id: plan.id,
|
|
424
|
+
...(plan.notify?.on ? { notify: true } : {}),
|
|
430
425
|
schedule: { every: template.setup.schedule.every, ...(startAt ? { start_at: startAt } : {}) },
|
|
431
426
|
task_name: plan.name,
|
|
432
427
|
});
|
|
@@ -437,7 +432,7 @@ export async function createWorkflow(input, deps) {
|
|
|
437
432
|
scheduleError = err instanceof Error ? err.message : String(err);
|
|
438
433
|
}
|
|
439
434
|
}
|
|
440
|
-
|
|
435
|
+
markWorkflow(deps.agentDir(agent.name));
|
|
441
436
|
return {
|
|
442
437
|
agent: agent.name,
|
|
443
438
|
newAgent: false,
|
|
@@ -450,50 +445,122 @@ export async function createWorkflow(input, deps) {
|
|
|
450
445
|
...(loadError ? { loadError } : {}),
|
|
451
446
|
};
|
|
452
447
|
}
|
|
453
|
-
|
|
448
|
+
const RECENT_RUNS = 5;
|
|
449
|
+
/** The live tasks that run a workflow: those carrying its id. */
|
|
450
|
+
function workflowTasks(id, tasks) {
|
|
451
|
+
return tasks
|
|
452
|
+
.filter((t) => t.workflow_id === id && t.status !== 'cancelled' && t.status !== 'done')
|
|
453
|
+
.sort((a, b) => Number('every' in b.schedule) - Number('every' in a.schedule));
|
|
454
|
+
}
|
|
455
|
+
function scheduleView(task) {
|
|
456
|
+
return {
|
|
457
|
+
taskId: task.task_id,
|
|
458
|
+
status: task.status,
|
|
459
|
+
repeating: 'every' in task.schedule,
|
|
460
|
+
...(task.params ? { params: task.params } : {}),
|
|
461
|
+
...(task.next_run_at ? { nextRunAt: task.next_run_at } : {}),
|
|
462
|
+
...(task.last_run_at ? { lastRunAt: task.last_run_at } : {}),
|
|
463
|
+
...(task.last_status ? { lastStatus: task.last_status } : {}),
|
|
464
|
+
waiting: Boolean(task.pending) || task.status === 'awaiting_approval',
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
/** Moves an agent's workflows.yml, from before workflows were skills, into
|
|
468
|
+
* its skills, and points the schedules it linked by name at the workflows
|
|
469
|
+
* they run. Safe to call any time: it does nothing once the file is gone. */
|
|
470
|
+
export async function migrateAgentWorkflows(agentName, agentDir) {
|
|
471
|
+
const moved = migrateWorkflowsFile(agentDir);
|
|
472
|
+
if (!moved.length)
|
|
473
|
+
return;
|
|
474
|
+
for (const task of await listScheduledTasks({ agent: agentName })) {
|
|
475
|
+
const match = !task.workflow_id && task.status !== 'cancelled' && moved.find((m) => m.scheduleName && m.scheduleName === task.task_name);
|
|
476
|
+
if (!match)
|
|
477
|
+
continue;
|
|
478
|
+
await getSchedulerStore().update(task.task_id, (t) => t && { ...t, workflow_id: match.id, message: '' });
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
/** An agent's workflows, each with the state of the tasks that run it. */
|
|
454
482
|
export async function listAgentWorkflows(agentName, agentDir) {
|
|
483
|
+
await migrateAgentWorkflows(agentName, agentDir);
|
|
455
484
|
const workflows = readWorkflows(agentDir);
|
|
456
485
|
if (!workflows.length)
|
|
457
486
|
return [];
|
|
458
487
|
const tasks = await listScheduledTasks({ agent: agentName });
|
|
488
|
+
const runs = await listRuns(agentName);
|
|
459
489
|
return workflows.map((w) => {
|
|
460
|
-
const
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
...w,
|
|
464
|
-
schedule: {
|
|
465
|
-
taskId: task.task_id,
|
|
466
|
-
status: task.status,
|
|
467
|
-
...(task.next_run_at ? { nextRunAt: task.next_run_at } : {}),
|
|
468
|
-
...(task.last_run_at ? { lastRunAt: task.last_run_at } : {}),
|
|
469
|
-
...(task.last_status ? { lastStatus: task.last_status } : {}),
|
|
470
|
-
waiting: Boolean(task.pending) || task.status === 'awaiting_approval',
|
|
471
|
-
},
|
|
472
|
-
}
|
|
473
|
-
: w;
|
|
490
|
+
const schedules = workflowTasks(w.id, tasks).map(scheduleView);
|
|
491
|
+
const recentRuns = runs.filter((r) => r.workflow_id === w.id).slice(0, RECENT_RUNS);
|
|
492
|
+
return { ...w, schedules, recentRuns, ...(schedules[0] ? { schedule: schedules[0] } : {}) };
|
|
474
493
|
});
|
|
475
494
|
}
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
495
|
+
function sameParams(a, b) {
|
|
496
|
+
const left = a ?? {};
|
|
497
|
+
const keys = new Set([...Object.keys(left), ...Object.keys(b)]);
|
|
498
|
+
return [...keys].every((k) => (left[k] ?? '') === (b[k] ?? ''));
|
|
499
|
+
}
|
|
500
|
+
/** Gets a run of a workflow ready to start, with these parameters: checks
|
|
501
|
+
* them, and that a run with the same parameters isn't already under way
|
|
502
|
+
* (runs with other parameters go ahead), and works out the message it
|
|
503
|
+
* starts with — the workflow's start message and steps as they are now.
|
|
504
|
+
* The server starts it (core/workflow-runs.ts). */
|
|
505
|
+
export async function prepareWorkflowRun(agentName, agentDir, id, rawParams) {
|
|
506
|
+
const workflow = readWorkflow(agentDir, id);
|
|
481
507
|
if (!workflow)
|
|
482
508
|
throw new WorkflowError(`No workflow ${id} in ${agentName}.`);
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
509
|
+
let params;
|
|
510
|
+
try {
|
|
511
|
+
params = validateParams(workflow, rawParams);
|
|
512
|
+
}
|
|
513
|
+
catch (err) {
|
|
514
|
+
throw new WorkflowError(err instanceof Error ? err.message : String(err));
|
|
515
|
+
}
|
|
516
|
+
const runs = await listRuns(agentName, { workflowId: id });
|
|
517
|
+
if (runs.some((r) => isUnderWay(r) && sameParams(r.params, params))) {
|
|
518
|
+
throw new WorkflowError(`${workflow.title} is already running${Object.keys(params).length ? ' with these parameters' : ''}, or waiting for a decision.`);
|
|
490
519
|
}
|
|
491
|
-
|
|
492
|
-
return { taskId: once.task_id };
|
|
520
|
+
return { workflow, params, message: workflowRunMessage(workflow, params) };
|
|
493
521
|
}
|
|
494
|
-
/**
|
|
495
|
-
|
|
496
|
-
|
|
522
|
+
/** Changes how a workflow's runs start, from the console's workflow
|
|
523
|
+
* editor: its title, start message, trigger and parameters. Its steps
|
|
524
|
+
* (the skill body) and the skill's other front matter are kept. */
|
|
525
|
+
export function updateWorkflow(agentDir, id, input) {
|
|
526
|
+
if (!readWorkflow(agentDir, id))
|
|
527
|
+
throw new WorkflowError(`No workflow ${id}.`);
|
|
528
|
+
const text = (value, field, max) => {
|
|
529
|
+
if (value !== undefined && value !== null && typeof value !== 'string')
|
|
530
|
+
throw new WorkflowError(`${field} must be text.`);
|
|
531
|
+
const trimmed = (value ?? '').toString().trim();
|
|
532
|
+
if (trimmed.length > max)
|
|
533
|
+
throw new WorkflowError(`${field} is too long: keep it under ${max} characters.`);
|
|
534
|
+
return trimmed;
|
|
535
|
+
};
|
|
536
|
+
const title = text(input.title, 'The title', 120);
|
|
537
|
+
const startMessage = text(input.start_message, 'The start message', 4000);
|
|
538
|
+
if (!startMessage)
|
|
539
|
+
throw new WorkflowError('Write the start message: what each run begins with, e.g. "Check the refund request for order $order_number."');
|
|
540
|
+
const trigger = text(input.trigger, 'When it runs', 200);
|
|
541
|
+
const raw = input.parameters ?? {};
|
|
542
|
+
if (typeof raw !== 'object' || Array.isArray(raw))
|
|
543
|
+
throw new WorkflowError('parameters must be an object of names to { description, required }.');
|
|
544
|
+
const parameters = {};
|
|
545
|
+
for (const [name, spec] of Object.entries(raw)) {
|
|
546
|
+
if (!/^[a-z_][a-z0-9_]*$/.test(name))
|
|
547
|
+
throw new WorkflowError(`Parameter "${name}": use lowercase letters, digits and _, starting with a letter, e.g. "order_number".`);
|
|
548
|
+
const s = spec && typeof spec === 'object' ? spec : {};
|
|
549
|
+
parameters[name] = { description: text(s.description, `The description of "${name}"`, 300), required: s.required === true };
|
|
550
|
+
}
|
|
551
|
+
setWorkflowFields(agentDir, id, { ...(title ? { title } : {}), startMessage, ...(trigger ? { trigger } : {}), parameters });
|
|
552
|
+
return readWorkflow(agentDir, id);
|
|
553
|
+
}
|
|
554
|
+
/** Forgets a workflow: its skill stays, as a plain skill, and the
|
|
555
|
+
* schedules that ran it are cancelled. Returns how many were. */
|
|
556
|
+
export async function forgetWorkflow(agentName, agentDir, id) {
|
|
557
|
+
clearWorkflowFields(agentDir, id);
|
|
558
|
+
let cancelled = 0;
|
|
559
|
+
for (const task of workflowTasks(id, await listScheduledTasks({ agent: agentName }))) {
|
|
560
|
+
await cancelScheduledTask(task.task_id);
|
|
561
|
+
cancelled++;
|
|
562
|
+
}
|
|
563
|
+
return { cancelled };
|
|
497
564
|
}
|
|
498
565
|
export function agentDirIn(projectDir, name) {
|
|
499
566
|
return join(projectDir, 'agents', name);
|
|
@@ -28,6 +28,12 @@ export const workflowPageHtml = `<!doctype html>
|
|
|
28
28
|
.wf-num { width: 24px; color: var(--ink-muted); }
|
|
29
29
|
.wf-target { display: grid; gap: 8px; }
|
|
30
30
|
.wf-target label { display: flex; gap: 8px; align-items: center; font-size: 13.5px; }
|
|
31
|
+
.wf-notify { display: flex; gap: 8px; align-items: center; font-size: 13.5px; }
|
|
32
|
+
.wf-notify input { margin: 0; }
|
|
33
|
+
.wf-params { display: grid; gap: 6px; }
|
|
34
|
+
.wf-param { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; font-size: 13px; }
|
|
35
|
+
.wf-param .wf-param-name { width: 160px; font-family: var(--mono, monospace); }
|
|
36
|
+
.wf-param .wf-param-desc { flex: 1; min-width: 200px; }
|
|
31
37
|
.wf-gaps { border: 1px solid var(--warn); background: var(--warn-soft); border-radius: var(--radius); padding: 10px 14px; }
|
|
32
38
|
.wf-gaps ul, .wf-list { margin: 4px 0 0; padding-left: 18px; }
|
|
33
39
|
.wf-gaps li, .wf-list li { margin: 3px 0; font-size: 13px; }
|
|
@@ -174,6 +180,54 @@ ${consoleShellScript}
|
|
|
174
180
|
'</div>';
|
|
175
181
|
}
|
|
176
182
|
|
|
183
|
+
// The $names a plan's start message and steps use: those parameters
|
|
184
|
+
// can't be removed here (the text needs them), only described.
|
|
185
|
+
function usedParamNames(p) {
|
|
186
|
+
var used = {};
|
|
187
|
+
var pattern = new RegExp('[$]([a-z_][a-z0-9_]*)', 'g');
|
|
188
|
+
var text = (p.startMessage || '') + ' ' + ((p.skill && p.skill.body) || '');
|
|
189
|
+
var m;
|
|
190
|
+
while ((m = pattern.exec(text))) used[m[1]] = true;
|
|
191
|
+
return used;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function paramRowHtml(name, spec, used) {
|
|
195
|
+
var fixed = name !== null;
|
|
196
|
+
return '<div class="wf-param"' + (fixed ? ' data-name="' + esc(name) + '"' : '') + '>' +
|
|
197
|
+
(fixed ? '<code class="wf-param-name">' + esc(name) + '</code>' : '<input class="wf-param-name" aria-label="Parameter name" placeholder="order_number">') +
|
|
198
|
+
'<input class="wf-param-desc" aria-label="What it is" placeholder="What it is" value="' + esc((spec && spec.description) || '') + '">' +
|
|
199
|
+
'<label><input type="checkbox" class="wf-param-required"' + (spec && spec.required ? ' checked' : '') + '> Required</label>' +
|
|
200
|
+
(fixed && used[name] ? '<span class="hint">used in the steps</span>' : '<button type="button" class="wf-btn plain wf-param-remove">Remove</button>') +
|
|
201
|
+
'</div>';
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function paramsSectionHtml(p) {
|
|
205
|
+
var used = usedParamNames(p);
|
|
206
|
+
var names = Object.keys(p.parameters || {});
|
|
207
|
+
return '<h3>What each run is given</h3>' +
|
|
208
|
+
'<p class="hint" style="margin:0"><strong>Each run starts with:</strong> ' + esc(p.startMessage) + '</p>' +
|
|
209
|
+
'<p class="hint" style="margin:0">' + (names.length
|
|
210
|
+
? 'Apps and people fill these in when they start a run; each $name in the start message and steps becomes its value.'
|
|
211
|
+
: 'Every run is the same. Add a parameter if something changes from run to run, such as an order number.') + '</p>' +
|
|
212
|
+
'<div class="wf-params" id="wfParams">' + names.map(function (n) { return paramRowHtml(n, p.parameters[n], used); }).join('') + '</div>' +
|
|
213
|
+
'<div><button type="button" class="wf-btn plain" id="wfAddParam">Add a parameter</button></div>';
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// The parameters as edited on the plan; an error message for a bad name.
|
|
217
|
+
function readParams() {
|
|
218
|
+
var parameters = {};
|
|
219
|
+
var rows = page.querySelectorAll('#wfParams .wf-param');
|
|
220
|
+
for (var i = 0; i < rows.length; i++) {
|
|
221
|
+
var input = rows[i].querySelector('input.wf-param-name');
|
|
222
|
+
var name = rows[i].dataset.name || (input ? input.value.trim() : '');
|
|
223
|
+
if (!name) continue;
|
|
224
|
+
if (!/^[a-z_][a-z0-9_]*$/.test(name)) return { error: 'Parameter "' + name + '": use lowercase letters, digits and _, starting with a letter, e.g. order_number.' };
|
|
225
|
+
if (parameters[name]) return { error: 'Parameter "' + name + '" is listed twice.' };
|
|
226
|
+
parameters[name] = { description: rows[i].querySelector('.wf-param-desc').value.trim(), required: rows[i].querySelector('.wf-param-required').checked };
|
|
227
|
+
}
|
|
228
|
+
return { parameters: parameters };
|
|
229
|
+
}
|
|
230
|
+
|
|
177
231
|
function renderPlan(error) {
|
|
178
232
|
var p = state.planned.plan;
|
|
179
233
|
var suggested = p.target.kind === 'existing' && p.target.agent ? p.target.agent : '';
|
|
@@ -182,6 +236,11 @@ ${consoleShellScript}
|
|
|
182
236
|
'<div class="wf-card">' +
|
|
183
237
|
'<h2>' + esc(p.name) + '</h2><p style="margin:0">' + esc(p.summary) + '</p>' +
|
|
184
238
|
'<p class="hint" style="margin:0"><strong>Starts:</strong> ' + esc(p.trigger.description) + (p.trigger.kind === 'schedule' ? ' (created paused; you turn it on)' : '') + '</p>' +
|
|
239
|
+
(p.trigger.kind === 'schedule'
|
|
240
|
+
? '<label class="wf-notify"><input type="checkbox" id="wfNotify"' + (p.notify && p.notify.on ? ' checked' : '') + '> Send each run' + String.fromCharCode(8217) + 's result to you through the agent' + String.fromCharCode(8217) + 's messaging app</label>' +
|
|
241
|
+
(p.notify && p.notify.reason ? '<p class="hint" style="margin:0 0 0 24px">' + esc(p.notify.reason) + '</p>' : '')
|
|
242
|
+
: '') +
|
|
243
|
+
paramsSectionHtml(p) +
|
|
185
244
|
'<h3>Steps</h3>' +
|
|
186
245
|
'<div class="le-table-wrap"><table class="wf-steps"><thead><tr><th></th><th>Step</th><th>Becomes</th><th>Checkpoint</th></tr></thead><tbody>' + p.steps.map(stepRow).join('') + '</tbody></table></div>' +
|
|
187
246
|
(p.rules.length ? '<h3>Permissions it sets</h3><ul class="wf-list">' + p.rules.map(function (r) { return '<li><code>' + esc(r.tool) + '</code> ' + pill(r.decision) + '</li>'; }).join('') + '</ul>' : '') +
|
|
@@ -206,7 +265,20 @@ ${consoleShellScript}
|
|
|
206
265
|
'<span class="hint" id="wfStatus"></span></div>' +
|
|
207
266
|
(error ? '<p class="error">' + esc(error) + '</p>' : '');
|
|
208
267
|
document.getElementById('wfBack').addEventListener('click', function () { renderDescribe(); });
|
|
268
|
+
var paramsBox = document.getElementById('wfParams');
|
|
269
|
+
paramsBox.addEventListener('click', function (ev) {
|
|
270
|
+
if (ev.target.classList.contains('wf-param-remove')) ev.target.closest('.wf-param').remove();
|
|
271
|
+
});
|
|
272
|
+
document.getElementById('wfAddParam').addEventListener('click', function () {
|
|
273
|
+
paramsBox.insertAdjacentHTML('beforeend', paramRowHtml(null, null, {}));
|
|
274
|
+
paramsBox.lastElementChild.querySelector('input.wf-param-name').focus();
|
|
275
|
+
});
|
|
209
276
|
document.getElementById('wfCreate').addEventListener('click', function () {
|
|
277
|
+
var edited = readParams();
|
|
278
|
+
if (edited.error) { document.getElementById('wfStatus').textContent = edited.error; return; }
|
|
279
|
+
p.parameters = edited.parameters;
|
|
280
|
+
var notifyBox = document.getElementById('wfNotify');
|
|
281
|
+
if (notifyBox) p.notify = { on: notifyBox.checked, reason: (p.notify && p.notify.reason) || '' };
|
|
210
282
|
var chosen = page.querySelector('input[name="wfTarget"]:checked');
|
|
211
283
|
var target = chosen.value === 'new' ? { kind: 'new' } : { kind: 'existing', agent: chosen.value === 'other' ? document.getElementById('wfOther').value : chosen.dataset.agent };
|
|
212
284
|
var btn = document.getElementById('wfCreate');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "loopengine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Runtime for tool-using AI agents with a transparent ReAct loop.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -124,6 +124,12 @@
|
|
|
124
124
|
"dist/core/durable-approvals.d.ts",
|
|
125
125
|
"dist/core/workflows.js",
|
|
126
126
|
"dist/core/workflows.d.ts",
|
|
127
|
+
"dist/core/workflow-runs.js",
|
|
128
|
+
"dist/core/workflow-runs.d.ts",
|
|
129
|
+
"dist/core/run-callbacks.js",
|
|
130
|
+
"dist/core/run-callbacks.d.ts",
|
|
131
|
+
"dist/core/workflow-triggers.js",
|
|
132
|
+
"dist/core/workflow-triggers.d.ts",
|
|
127
133
|
"dist/web",
|
|
128
134
|
"protocol/loop-event.schema.json",
|
|
129
135
|
"PROTOCOL.md",
|