loopengine 0.3.4 → 0.3.5

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.
@@ -8,17 +8,26 @@ export interface Workflow {
8
8
  /** A name for people, e.g. "Refund check". */
9
9
  title: string;
10
10
  description: string;
11
+ /** A workflow saved before instructions: what its runs began with. Empty
12
+ * for one saved since. */
11
13
  startMessage: string;
12
- /** When it runs, in plain words: "Every Monday at 9", "When asked". */
14
+ /** A label from before, in words ("Every Monday at 9"); "When asked"
15
+ * when there's none. Nothing reads it to decide when to run. */
13
16
  trigger: string;
14
17
  parameters: Record<string, WorkflowParameter>;
15
- /** Its steps: the skill body, placeholders unfilled. */
18
+ /** The skill body, placeholders unfilled. */
16
19
  steps: string;
20
+ /** What the agent does, as one text: the body, with an older
21
+ * workflow's start message in front of it. What the editor shows. */
22
+ instructions: string;
17
23
  }
18
- /** The front matter that makes a skill a workflow. */
24
+ /** The front matter that makes a skill a workflow: `instructions` (the
25
+ * new way) replaces the body and drops any start message and label;
26
+ * `startMessage` (from before) is kept for workflows saved that way. */
19
27
  export interface WorkflowFields {
20
28
  title?: string;
21
- startMessage: string;
29
+ instructions?: string;
30
+ startMessage?: string;
22
31
  trigger?: string;
23
32
  parameters?: Record<string, WorkflowParameter>;
24
33
  }
@@ -1,25 +1,34 @@
1
1
  // An agent's workflows: skills it can be told to run, by hand, from an app
2
2
  // over the API, or on a schedule. A workflow is a skill
3
- // (agents/<name>/skills/<id>/SKILL.md) whose front matter also says how a
4
- // run starts:
3
+ // (agents/<name>/skills/<id>/SKILL.md) marked as one, with a title and the
4
+ // values a run is given:
5
5
  //
6
6
  // ---
7
7
  // name: refund-check
8
8
  // description: Check a refund request against the order and our policy
9
+ // workflow: true
9
10
  // title: Refund check
10
- // start_message: Check the refund request for order $order_number.
11
- // trigger: When a refund request comes in
12
11
  // parameters:
13
12
  // order_number: { description: The order to check, required: true }
14
13
  // customer_note: { description: What the customer wrote }
15
14
  // ---
15
+ // Check the refund request for order $order_number.
16
16
  // 1. Look up order $order_number…
17
17
  //
18
- // The body is its steps. A run starts with the start message and those
19
- // steps, with each $parameter filled in, so the model has them from its
20
- // first turn rather than picking the skill from its index. None of these
21
- // keys reach the model otherwise: the skill index shows only a skill's
22
- // name and description (core/skillgarden).
18
+ // The body is its instructions: one text, edited as one. A run starts with
19
+ // a line naming the workflow and those instructions, with each $parameter
20
+ // filled in, so the model has them from its first turn rather than
21
+ // picking the skill from its index. In chat, the agent loads the same
22
+ // body through the Skill tool. None of the workflow keys reach the model
23
+ // otherwise: the skill index shows only a skill's name and description
24
+ // (core/skillgarden).
25
+ //
26
+ // What starts a run (schedules, webhooks) isn't kept here: it's the
27
+ // workflow's schedules and webhook triggers themselves.
28
+ //
29
+ // Workflows saved before had a separate start_message (a skill with one
30
+ // is still a workflow) and a `trigger` label; both are folded into the
31
+ // instructions the next time the workflow is saved.
23
32
  //
24
33
  // Agents from before kept their workflows in workflows.yml;
25
34
  // migrateWorkflowsFile moves those into their skills.
@@ -30,7 +39,7 @@ import { parseSkillFile, substituteParameters } from './skillgarden/index.js';
30
39
  export class WorkflowParamsError extends Error {
31
40
  }
32
41
  const PARAMETER_NAME = /^[a-z_][a-z0-9_]*$/;
33
- const WORKFLOW_KEYS = ['title', 'start_message', 'trigger', 'parameters'];
42
+ const WORKFLOW_KEYS = ['workflow', 'title', 'start_message', 'trigger', 'parameters'];
34
43
  function skillsDir(agentDir) {
35
44
  return join(agentDir, 'skills');
36
45
  }
@@ -72,9 +81,19 @@ function parametersFrom(value) {
72
81
  }
73
82
  return parameters;
74
83
  }
84
+ /** An older workflow's start message and body as one text; the body
85
+ * alone when it already begins with the start message. */
86
+ function combineInstructions(startMessage, body) {
87
+ const steps = body.trim();
88
+ if (!startMessage)
89
+ return steps;
90
+ if (steps.startsWith(startMessage))
91
+ return steps;
92
+ return steps ? `${startMessage}\n\n${steps}` : startMessage;
93
+ }
75
94
  function workflowFrom(id, frontmatter, body) {
76
95
  const startMessage = typeof frontmatter.start_message === 'string' ? frontmatter.start_message.trim() : '';
77
- if (!startMessage)
96
+ if (frontmatter.workflow !== true && !startMessage)
78
97
  return undefined;
79
98
  return {
80
99
  id,
@@ -84,6 +103,7 @@ function workflowFrom(id, frontmatter, body) {
84
103
  trigger: typeof frontmatter.trigger === 'string' && frontmatter.trigger.trim() ? frontmatter.trigger.trim() : 'When asked',
85
104
  parameters: parametersFrom(frontmatter.parameters),
86
105
  steps: body,
106
+ instructions: combineInstructions(startMessage, body),
87
107
  };
88
108
  }
89
109
  /** The agent's workflows: its skills that have a start message. */
@@ -121,12 +141,19 @@ export function setWorkflowFields(agentDir, id, fields) {
121
141
  const frontmatter = { ...skill.frontmatter };
122
142
  for (const key of WORKFLOW_KEYS)
123
143
  delete frontmatter[key];
144
+ const parameters = fields.parameters && Object.keys(fields.parameters).length ? { parameters: fields.parameters } : {};
145
+ if (fields.instructions !== undefined) {
146
+ writeSkillFile(path, { ...frontmatter, workflow: true, ...(fields.title ? { title: fields.title } : {}), ...parameters }, fields.instructions);
147
+ return;
148
+ }
149
+ if (!fields.startMessage)
150
+ throw new Error('A workflow needs its instructions.');
124
151
  writeSkillFile(path, {
125
152
  ...frontmatter,
126
153
  ...(fields.title ? { title: fields.title } : {}),
127
154
  start_message: fields.startMessage,
128
155
  ...(fields.trigger ? { trigger: fields.trigger } : {}),
129
- ...(fields.parameters && Object.keys(fields.parameters).length ? { parameters: fields.parameters } : {}),
156
+ ...parameters,
130
157
  }, skill.body);
131
158
  }
132
159
  /** Stops a skill being a workflow. The skill itself stays. */
@@ -182,13 +209,15 @@ export function workflowRunMessage(workflow, params = {}) {
182
209
  // Named placeholders only: positional $1-style filling would eat dollar
183
210
  // amounts like "$50" in the steps.
184
211
  const fill = (text) => substituteParameters(text, params, { declared, used });
185
- const start = fill(workflow.startMessage);
212
+ // An older workflow begins with its own start message; one saved since
213
+ // with a line naming it, its instructions saying the rest.
214
+ const start = workflow.startMessage ? fill(workflow.startMessage) : `Run the "${workflow.title}" workflow.`;
186
215
  const steps = fill(workflow.steps).trim();
187
216
  const unused = Object.entries(params).filter(([name]) => !used.has(name));
188
217
  return [
189
218
  start,
190
219
  unused.length ? `Inputs:\n${unused.map(([name, value]) => `- ${name}: ${value}`).join('\n')}` : '',
191
- steps ? `These are the steps, from your "${workflow.id}" skill:\n\n${steps}` : '',
220
+ steps ? `${workflow.startMessage ? 'These are the steps' : 'These are its instructions'}, from your "${workflow.id}" skill:\n\n${steps}` : '',
192
221
  ].filter(Boolean).join('\n\n');
193
222
  }
194
223
  export function legacyWorkflowsPath(agentDir) {
@@ -667,8 +667,9 @@ ${consoleShellScript}
667
667
  (cfg.abilityCount != null
668
668
  ? card('abilities', 'Apps', String(cfg.abilityCount), cfg.abilityCount ? 'Apps and abilities added to this agent' : 'Add apps and abilities, with the tools you choose')
669
669
  : card('abilities', 'Apps', 'Browse', 'Add apps and abilities, with the tools you choose')) +
670
- card('tools', 'Tools', String(cfg.tools.length), escapeHtml(toolNames.slice(0, 3).join(', ') + (toolNames.length > 3 ? ', \u2026' : '')) || 'None yet') +
671
- card('skills', 'Skills', String(cfg.skills.length), escapeHtml(cfg.skills.map(function (k) { return k.name; }).join(', ')) || 'None yet') +
670
+ card('workflows', 'Workflows', '<span id="ovWorkflowsValue">&hellip;</span>', '<span id="ovWorkflowsDetail">Loading&hellip;</span>') +
671
+ card('tools', 'Tools &amp; skills', cfg.tools.length + (cfg.tools.length === 1 ? ' tool' : ' tools'),
672
+ (cfg.skills.length + (cfg.skills.length === 1 ? ' skill' : ' skills')) + (toolNames.length ? ' \u00b7 ' + escapeHtml(toolNames.slice(0, 2).join(', ') + (toolNames.length > 2 ? ', \u2026' : '')) : '')) +
672
673
  card('actauth', 'Permissions', rules.length + (rules.length === 1 ? ' rule' : ' rules'),
673
674
  (asks ? asks + ' need approval \u00b7 ' : '') + 'unmatched calls: ' + escapeHtml(cfg.permissions.defaultDecision)) +
674
675
  card('messaging', 'Messaging', '<span id="ovMessagingValue">&hellip;</span>', '<span id="ovMessagingDetail">Loading&hellip;</span>') +
@@ -683,8 +684,48 @@ ${consoleShellScript}
683
684
  // cards are filled in once those answer. Called after every render of
684
685
  // Overview, which is also re-rendered after edits.
685
686
  // ---- Overview: the agent's workflows (web/workflow-admin.ts), added
686
- // from "Describe a workflow": each with its trigger, its schedule's
687
- // state, its steps (checkpoints marked) and Run now. ----
687
+ // from "Describe a workflow": each with its instructions, what starts it
688
+ // (its real schedules and webhooks), its recent runs and Run now. ----
689
+
690
+ // What starts a workflow, in a few words, from its schedules and
691
+ // webhooks: never a label that could say something else.
692
+ function workflowStartsSummary(w) {
693
+ var parts = (w.schedules || []).filter(function (x) { return x.repeating && x.status !== 'paused'; }).map(function (x) { return x.describe; });
694
+ if (w.webhooks) parts.push(w.webhooks === 1 ? 'when its webhook is called' : 'when one of its ' + w.webhooks + ' webhooks is called');
695
+ if (!parts.length) return 'When asked';
696
+ var text = parts.join(', and ');
697
+ return text.charAt(0).toUpperCase() + text.slice(1);
698
+ }
699
+
700
+ // How it starts: each schedule (pause, resume, remove), its webhooks,
701
+ // and Run now or the API, which always work.
702
+ function workflowStartsHtml(w) {
703
+ var schedules = (w.schedules || []).map(function (x) {
704
+ var state = x.waiting ? '<span class="le-pill ask">Waiting for a decision</span>'
705
+ : x.status === 'paused' ? '<span class="le-pill">Paused</span>'
706
+ : x.status === 'running' ? '<span class="le-pill accent">Running</span>'
707
+ : (x.nextRunAt ? '<span class="hint">next ' + escapeHtml(new Date(x.nextRunAt).toLocaleString()) + '</span>' : '');
708
+ var values = x.params ? Object.keys(x.params).map(function (k) { return escapeHtml(k) + ': ' + escapeHtml(x.params[k]); }).join(' ' + String.fromCharCode(183) + ' ') : '';
709
+ return '<li style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">' +
710
+ '<span>' + escapeHtml(x.describe.charAt(0).toUpperCase() + x.describe.slice(1)) + (x.name ? ' <span class="hint">(' + escapeHtml(x.name) + ')</span>' : '') + '</span>' + state +
711
+ (values ? '<span class="hint">' + values + '</span>' : '') +
712
+ (x.notify ? '<span class="hint">sends you each result</span>' : '') +
713
+ '<span style="display:flex;gap:6px;margin-left:auto">' +
714
+ (x.status === 'paused' ? '<button type="button" class="workflow-schedule-action" data-id="' + escapeHtml(x.taskId) + '" data-action="resume">Resume</button>'
715
+ : x.status === 'scheduled' ? '<button type="button" class="workflow-schedule-action" data-id="' + escapeHtml(x.taskId) + '" data-action="pause">Pause</button>' : '') +
716
+ '<button type="button" class="delete-btn workflow-schedule-action" data-id="' + escapeHtml(x.taskId) + '" data-action="cancel">Remove</button>' +
717
+ '</span></li>';
718
+ }).join('');
719
+ return '<div class="body-field workflow-starts" style="margin:6px 0 0">How it starts' +
720
+ '<ul class="schedule-history" style="margin:4px 0 0">' + schedules +
721
+ (w.webhooks ? '<li>' + (w.webhooks === 1 ? 'A webhook' : w.webhooks + ' webhooks') + ' <span class="hint">another app calls it when something happens</span></li>' : '') +
722
+ '<li class="hint">Run now, below, or an app' + String.fromCharCode(8217) + 's API call, any time</li>' +
723
+ '</ul>' +
724
+ '<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:6px">' +
725
+ '<button type="button" class="workflow-add-schedule">Add a schedule</button>' +
726
+ '<button type="button" class="workflow-hooks-btn">' + (w.webhooks ? 'Webhooks' : 'Add a webhook') + '</button>' +
727
+ '</div></div>';
728
+ }
688
729
 
689
730
  function renderWorkflowsHtml(name, workflows) {
690
731
  if (!workflows.length) {
@@ -693,7 +734,7 @@ ${consoleShellScript}
693
734
  return workflows.map(function (w) {
694
735
  var s = w.schedule;
695
736
  var state = !s
696
- ? '<span class="hint">' + escapeHtml(w.trigger) + '</span>'
737
+ ? '<span class="hint">' + escapeHtml(workflowStartsSummary(w)) + '</span>'
697
738
  : s.waiting
698
739
  ? '<span class="le-pill ask">Waiting for a decision</span>'
699
740
  : s.status === 'paused'
@@ -707,7 +748,6 @@ ${consoleShellScript}
707
748
  return '<label class="workflow-param">' + escapeHtml(p) + (spec.required ? '' : ' <span class="hint">(optional)</span>') +
708
749
  '<input name="' + escapeHtml(p) + '"' + (spec.required ? ' required' : '') + ' placeholder="' + escapeHtml(spec.description || '') + '"></label>';
709
750
  }).join('');
710
- var scheduleCount = (w.schedules || []).filter(function (x) { return x.repeating; }).length;
711
751
  var runs = (w.recentRuns || []).map(function (r) {
712
752
  var pill = r.status === 'done' ? '<span class="le-pill good">Done</span>'
713
753
  : r.status === 'error' ? '<span class="le-pill bad">Failed</span>'
@@ -722,22 +762,44 @@ ${consoleShellScript}
722
762
  return '<div class="source workflow-card" data-workflow="' + escapeHtml(w.id) + '">' +
723
763
  '<div style="display:flex;justify-content:space-between;gap:10px;flex-wrap:wrap;align-items:baseline">' +
724
764
  '<strong>' + escapeHtml(w.title) + '</strong><span>' + state + '</span></div>' +
725
- '<p class="hint" style="margin:4px 0 6px">' + escapeHtml(w.description) + (s ? ' ' + String.fromCharCode(183) + ' ' + escapeHtml(w.trigger) : '') + '</p>' +
726
- '<details><summary class="hint">Steps <span>(the ' + escapeHtml(w.id) + ' skill)</span></summary><div class="workflow-steps-text">' + escapeHtml(w.steps) + '</div></details>' +
765
+ '<p class="hint" style="margin:4px 0 6px">' + escapeHtml(w.description) + (s ? ' ' + String.fromCharCode(183) + ' ' + escapeHtml(workflowStartsSummary(w)) : '') + '</p>' +
766
+ '<details><summary class="hint">Instructions <span>(the ' + escapeHtml(w.id) + ' skill)</span></summary><div class="workflow-steps-text">' + escapeHtml(w.instructions) + '</div></details>' +
767
+ workflowStartsHtml(w) +
727
768
  (params ? '<div class="workflow-params">' + params + '</div>' : '') +
728
769
  (runs ? '<details class="workflow-runs"' + ((w.recentRuns || []).some(function (r) { return r.status === 'running' || r.status === 'awaiting_approval'; }) ? ' open' : '') + '><summary class="hint">Recent runs (' + w.recentRuns.length + ')</summary><ul class="schedule-history">' + runs + '</ul></details>' : '') +
729
770
  '<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:8px">' +
730
771
  '<button type="button" class="workflow-run">Run now</button>' +
731
- (scheduleCount ? '<a href="?tab=schedules" data-tab="schedules" class="hint" style="align-self:center">' + (scheduleCount > 1 ? 'Schedules (' + scheduleCount + ')' : 'Schedule') + '</a>' : '') +
732
772
  '<button type="button" class="workflow-edit">Edit</button>' +
733
- '<button type="button" class="workflow-hooks-btn">Webhooks</button>' +
734
- '<a href="?tab=skills" data-tab="skills" class="hint" style="align-self:center">Edit steps</a>' +
735
773
  '<button type="button" class="delete-btn workflow-forget">Forget</button>' +
736
774
  '</div><div class="hint workflow-result"></div><div class="workflow-editor" hidden></div><div class="workflow-hooks" hidden></div></div>';
737
775
  }).join('') + '<p class="hint" style="margin:6px 0 0"><a href="/workflows/new">Describe another workflow</a></p>';
738
776
  }
739
777
 
778
+ // The Overview's Workflows tile: how many, the next scheduled run, and
779
+ // any waiting for a decision.
780
+ function fillWorkflowsCard(workflows) {
781
+ var value = detail.querySelector('#ovWorkflowsValue');
782
+ var detailEl = detail.querySelector('#ovWorkflowsDetail');
783
+ if (!value || !detailEl) return;
784
+ value.textContent = String(workflows.length);
785
+ var next = null;
786
+ var waiting = 0;
787
+ workflows.forEach(function (w) {
788
+ (w.schedules || []).forEach(function (x) {
789
+ if (x.waiting) waiting++;
790
+ if (x.status === 'scheduled' && x.nextRunAt && (!next || x.nextRunAt < next)) next = x.nextRunAt;
791
+ });
792
+ (w.recentRuns || []).forEach(function (r) { if (r.status === 'awaiting_approval') waiting++; });
793
+ });
794
+ var parts = [];
795
+ if (waiting) parts.push(waiting + ' waiting for a decision');
796
+ if (next) parts.push('next run ' + new Date(next).toLocaleString());
797
+ detailEl.textContent = parts.join(' \u00b7 ') || (workflows.length ? 'Run by hand, by an app or a webhook' : 'Describe a job for this agent to do');
798
+ }
799
+
740
800
  var workflowsRefreshTimer = null;
801
+ // Set by a workflow's "Add a schedule", for the Schedules tab's form.
802
+ var pendingScheduleWorkflow = null;
741
803
  var workflowsById = {};
742
804
  // Set while a workflow's editor is open, so the running-run refresh
743
805
  // doesn't redraw the card under someone typing.
@@ -751,6 +813,7 @@ ${consoleShellScript}
751
813
  if (currentName !== name) return;
752
814
  if (!r.ok) { el.innerHTML = '<p class="error">Could not load workflows: ' + escapeHtml(r.body.error || 'unknown error') + '</p>'; return; }
753
815
  var workflows = r.body.workflows || [];
816
+ fillWorkflowsCard(workflows);
754
817
  workflowsById = {};
755
818
  workflows.forEach(function (w) { workflowsById[w.id] = w; });
756
819
  el.innerHTML = renderWorkflowsHtml(name, workflows);
@@ -758,12 +821,12 @@ ${consoleShellScript}
758
821
  for (var i = 0; i < cards.length; i++) wireWorkflowCard(name, cards[i]);
759
822
  // Keep a running run's status current while the Overview is open.
760
823
  var running = workflows.some(function (w) { return (w.recentRuns || []).some(function (x) { return x.status === 'running'; }); });
761
- if (running && currentTab === 'overview') workflowsRefreshTimer = setTimeout(function () { if (!workflowEditing) loadWorkflowsSection(name); }, 3000);
824
+ if (running && currentTab === 'workflows') workflowsRefreshTimer = setTimeout(function () { if (!workflowEditing) loadWorkflowsSection(name); }, 3000);
762
825
  });
763
826
  }
764
827
 
765
- // ---- The workflow editor: how a workflow's runs start. Its steps are
766
- // the skill body, edited on the Skills tab.
828
+ // ---- The workflow editor: its title, its instructions (the skill body)
829
+ // and the values a run is given. What starts it is on the card.
767
830
  function workflowParamRowHtml(pname, spec) {
768
831
  return '<div class="workflow-param-row">' +
769
832
  '<input class="wp-name" aria-label="Parameter name" placeholder="order_number" value="' + escapeHtml(pname || '') + '">' +
@@ -776,9 +839,8 @@ ${consoleShellScript}
776
839
  var rows = Object.keys(w.parameters || {}).map(function (p) { return workflowParamRowHtml(p, w.parameters[p]); }).join('');
777
840
  return '<form class="add-source workflow-editor-form" style="margin-top:10px">' +
778
841
  '<label>Title<input name="title" maxlength="120" value="' + escapeHtml(w.title) + '"></label>' +
779
- '<label>Start message <span class="hint">(what each run begins with; $name puts in a parameter&#39;s value)</span>' +
780
- '<textarea name="start_message" maxlength="4000" required>' + escapeHtml(w.startMessage) + '</textarea></label>' +
781
- '<label>When it runs <span class="hint">(in words, for people; the timing itself is its schedule)</span><input name="trigger" maxlength="200" value="' + escapeHtml(w.trigger) + '"></label>' +
842
+ '<label>Instructions <span class="hint">(what the agent does, step by step; $name puts in a parameter&#39;s value. Chat uses them too.)</span>' +
843
+ '<textarea name="instructions" maxlength="12000" rows="12" required>' + escapeHtml(w.instructions) + '</textarea></label>' +
782
844
  '<div class="body-field">Parameters <span class="hint">(values a run is given; apps send them as params)</span>' +
783
845
  '<div class="workflow-param-rows">' + rows + '</div>' +
784
846
  '<button type="button" class="wp-add" style="align-self:flex-start">Add parameter</button></div>' +
@@ -787,12 +849,12 @@ ${consoleShellScript}
787
849
  '<div class="hint workflow-editor-result"></div></form>';
788
850
  }
789
851
 
790
- // Placeholders used in the start message or steps that no parameter
791
- // declares, and declared parameters nothing uses.
852
+ // Placeholders the instructions use that no parameter declares, and
853
+ // declared parameters nothing uses.
792
854
  function workflowPlaceholderCheck(w, form) {
793
855
  var used = {};
794
856
  var pattern = new RegExp('[$]([a-z_][a-z0-9_]*)', 'g');
795
- var text = form.querySelector('[name="start_message"]').value + ' ' + (w.steps || '');
857
+ var text = form.querySelector('[name="instructions"]').value;
796
858
  var m;
797
859
  while ((m = pattern.exec(text))) used[m[1]] = true;
798
860
  var declared = {};
@@ -802,7 +864,7 @@ ${consoleShellScript}
802
864
  var missing = Object.keys(used).filter(function (n) { return !declared[n]; });
803
865
  var unused = Object.keys(declared).filter(function (n) { return !used[n]; });
804
866
  if (missing.length) notes.push('Uses ' + missing.map(function (n) { return '$' + n; }).join(', ') + ', which ' + (missing.length > 1 ? 'aren' : 'isn') + String.fromCharCode(8217) + 't a parameter: add ' + (missing.length > 1 ? 'them' : 'it') + ', or the text stays as written.');
805
- if (unused.length) notes.push(unused.join(', ') + ' ' + (unused.length > 1 ? 'aren' : 'isn') + String.fromCharCode(8217) + 't used in the start message or steps: its value is passed as an input.');
867
+ if (unused.length) notes.push(unused.join(', ') + ' ' + (unused.length > 1 ? 'aren' : 'isn') + String.fromCharCode(8217) + 't used in the instructions: its value is passed as an input.');
806
868
  form.querySelector('.workflow-editor-check').textContent = notes.join(' ');
807
869
  }
808
870
 
@@ -843,7 +905,7 @@ ${consoleShellScript}
843
905
  result.textContent = 'Saving' + String.fromCharCode(8230);
844
906
  fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/workflows/' + encodeURIComponent(w.id), {
845
907
  method: 'PUT', headers: { 'content-type': 'application/json' },
846
- body: JSON.stringify({ title: form.title.value, start_message: form.start_message.value, trigger: form.trigger.value, parameters: parameters }),
908
+ body: JSON.stringify({ title: form.title.value, instructions: form.instructions.value, parameters: parameters }),
847
909
  }).then(function (r) {
848
910
  if (!r.ok) { result.textContent = r.body.error || 'Could not save'; return; }
849
911
  workflowEditing = false;
@@ -1040,6 +1102,21 @@ ${consoleShellScript}
1040
1102
  var base = '/agents/' + encodeURIComponent(name) + '/workflows/' + encodeURIComponent(id);
1041
1103
  card.querySelector('.workflow-edit').addEventListener('click', function () { openWorkflowEditor(name, card); });
1042
1104
  card.querySelector('.workflow-hooks-btn').addEventListener('click', function () { openWorkflowHooks(name, card); });
1105
+ // Add a schedule: the Schedules tab's form, with this workflow chosen.
1106
+ card.querySelector('.workflow-add-schedule').addEventListener('click', function () {
1107
+ pendingScheduleWorkflow = id;
1108
+ switchTab('schedules');
1109
+ });
1110
+ var scheduleBtns = card.querySelectorAll('.workflow-schedule-action');
1111
+ for (var sb = 0; sb < scheduleBtns.length; sb++) {
1112
+ scheduleBtns[sb].addEventListener('click', function (ev) {
1113
+ var btn = ev.currentTarget;
1114
+ var action = btn.dataset.action;
1115
+ if (action === 'cancel' && btn.dataset.confirming !== '1') { btn.dataset.confirming = '1'; btn.textContent = 'Click again to remove'; return; }
1116
+ btn.disabled = true;
1117
+ fetchJsonRetryOnce('/schedules/' + encodeURIComponent(btn.dataset.id) + '/' + action, { method: 'POST' }).then(function () { loadWorkflowsSection(name); });
1118
+ });
1119
+ }
1043
1120
  card.querySelector('.workflow-run').addEventListener('click', function (ev) {
1044
1121
  var btn = ev.currentTarget;
1045
1122
  var params = {};
@@ -1153,7 +1230,6 @@ ${consoleShellScript}
1153
1230
  }
1154
1231
 
1155
1232
  return renderOverviewCards(cfg) +
1156
- '<section id="workflowsSection"><h3>Workflows</h3><div id="workflowsContent"><p class="hint">Loading&hellip;</p></div></section>' +
1157
1233
  '<section>' + systemPromptSection + '</section>' +
1158
1234
  '<section>' + modelSection + '</section>' +
1159
1235
  '<section><h3>Hooks</h3><dl class="kv">' +
@@ -2353,6 +2429,14 @@ ${consoleShellScript}
2353
2429
  '<input name="param:' + escapeHtml(p) + '" placeholder="' + escapeHtml(parameters[p].description || '') + '"></label>';
2354
2430
  }).join('');
2355
2431
  }
2432
+ // Opened from a workflow's "Add a schedule": that workflow, chosen.
2433
+ if (pendingScheduleWorkflow && workflowSelect) {
2434
+ workflowSelect.value = pendingScheduleWorkflow;
2435
+ var runsWorkflowRadio = form.querySelector('#runsWorkflow');
2436
+ if (runsWorkflowRadio) runsWorkflowRadio.checked = true;
2437
+ form.scrollIntoView({ block: 'start' });
2438
+ }
2439
+ pendingScheduleWorkflow = null;
2356
2440
  renderScheduleParams();
2357
2441
  if (workflowSelect) workflowSelect.addEventListener('change', renderScheduleParams);
2358
2442
  for (var k = 0; k < runsRadios.length; k++) {
@@ -4147,7 +4231,7 @@ ${consoleShellScript}
4147
4231
 
4148
4232
  // ---- Tab switching + agent selection ----
4149
4233
 
4150
- var CONFIG_TABS = ['overview', 'abilities', 'actauth', 'messaging', 'schedules', 'env', 'advanced'];
4234
+ var CONFIG_TABS = ['overview', 'workflows', 'abilities', 'actauth', 'messaging', 'schedules', 'env', 'advanced'];
4151
4235
  // The old Tools and Skills tabs: now parts of Advanced. A link to one
4152
4236
  // opens Advanced at that part.
4153
4237
  // The Apps tab keeps the id 'abilities'; ?tab=apps opens it too.
@@ -4284,6 +4368,11 @@ ${consoleShellScript}
4284
4368
  }) +
4285
4369
  '<div class="le-scroll"><div class="le-page" style="display:block">' +
4286
4370
  '<div class="tab-panel" data-tab-panel="overview">' + renderOverviewHtml(cfg) + '</div>' +
4371
+ // Workflows: the jobs this agent does, each with what starts it.
4372
+ '<div class="tab-panel" data-tab-panel="workflows">' +
4373
+ '<p class="muted" style="margin:0 0 20px">The jobs this agent does on its own: what it does, what starts it (schedules, webhooks, Run now or an app&#39;s API call) and how its runs went. In Chat it can do them too, when you ask.</p>' +
4374
+ '<section id="workflowsSection"><div id="workflowsContent"><p class="hint">Loading&hellip;</p></div></section>' +
4375
+ '</div>' +
4287
4376
  // Advanced: the agent's own tools (and Composio, subagents), then its
4288
4377
  // own skills. Each part keeps its old data-tab-panel name, which the
4289
4378
  // code that redraws it looks for; only .tab-panel is shown or hidden.
@@ -56,7 +56,7 @@ export const consoleShellScript = `<script>
56
56
  // ability on the Apps tab (id 'abilities', so older links still work).
57
57
  // ?tab=tools and ?tab=skills still open Advanced.
58
58
  var TABS = [
59
- ['overview', 'Overview'], ['chat', 'Chat'], ['abilities', 'Apps'], ['actauth', 'Permissions'],
59
+ ['overview', 'Overview'], ['workflows', 'Workflows'], ['chat', 'Chat'], ['abilities', 'Apps'], ['actauth', 'Permissions'],
60
60
  ['messaging', 'Messaging'], ['schedules', 'Schedules'], ['env', 'Environment'], ['advanced', 'Advanced']
61
61
  ];
62
62
 
@@ -5,6 +5,7 @@ import { type GeneratedMcpAbility, type McpAbilityRequest } from '../core/mcp-ab
5
5
  import type { McpDirectoryServer } from '../core/mcp-directory.js';
6
6
  import { type Workflow } from '../core/workflows.js';
7
7
  import { type WorkflowRun } from '../core/workflow-runs.js';
8
+ import { type ScheduledTask } from '../core/scheduler.js';
8
9
  import type { ModelCall } from '../core/run-agent.js';
9
10
  export declare class WorkflowError extends Error {
10
11
  }
@@ -162,6 +163,9 @@ type WorkflowScheduleView = {
162
163
  taskId: string;
163
164
  status: string;
164
165
  repeating: boolean;
166
+ describe: string;
167
+ name?: string;
168
+ notify: boolean;
165
169
  params?: Record<string, string>;
166
170
  nextRunAt?: string;
167
171
  lastRunAt?: string;
@@ -175,7 +179,12 @@ export interface AgentWorkflowView extends Workflow {
175
179
  schedules: WorkflowScheduleView[];
176
180
  /** Its latest runs started by hand or by an app, newest first. */
177
181
  recentRuns: WorkflowRun[];
182
+ /** Its webhook triggers that are on. */
183
+ webhooks: number;
178
184
  }
185
+ /** A schedule in words, in its time zone: "every day at 18:05", "every
186
+ * Monday at 09:00", "every 10 minutes", "once on Nov 6 at 18:05". */
187
+ export declare function plainSchedule(schedule: ScheduledTask['schedule']): string;
179
188
  /** Moves an agent's workflows.yml, from before workflows were skills, into
180
189
  * its skills, and points the schedules it linked by name at the workflows
181
190
  * they run. Safe to call any time: it does nothing once the file is gone. */
@@ -192,9 +201,11 @@ export declare function prepareWorkflowRun(agentName: string, agentDir: string,
192
201
  params: Record<string, string>;
193
202
  message: string;
194
203
  }>;
195
- /** Changes how a workflow's runs start, from the console's workflow
196
- * editor: its title, start message, trigger and parameters. Its steps
197
- * (the skill body) and the skill's other front matter are kept. */
204
+ /** Changes a workflow from the console's editor: its title, its
205
+ * instructions (the skill body, which an older workflow's start message
206
+ * and label are folded into), and its parameters. The skill's other front
207
+ * matter is kept. A body with `start_message` (and no `instructions`) is
208
+ * still taken, as before. */
198
209
  export declare function updateWorkflow(agentDir: string, id: string, input: Record<string, unknown>): Workflow;
199
210
  /** Forgets a workflow: its skill stays, as a plain skill, and the
200
211
  * schedules that ran it are cancelled. Returns how many were. */
@@ -11,12 +11,12 @@ 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
13
  import { planParameters, planWebhook, planWorkflowWithModel, slugify, workflowTemplate } from '../core/system-agents/workflow-planner.js';
14
- import { createTrigger, publicTrigger } from '../core/workflow-triggers.js';
14
+ import { createTrigger, listTriggers, publicTrigger } from '../core/workflow-triggers.js';
15
15
  import { mcpAbilityNames } from '../core/mcp-ability-generator.js';
16
16
  import { clearWorkflowFields, migrateWorkflowsFile, readWorkflow, readWorkflows, setWorkflowFields, validateParams, workflowRunMessage } from '../core/workflows.js';
17
17
  import { isUnderWay, listRuns } from '../core/workflow-runs.js';
18
- import { cancelScheduledTask, createScheduledTask, getSchedulerStore, listScheduledTasks, pauseScheduledTask } from '../core/scheduler.js';
19
- import { workspaceTimeZone } from '../core/time-zone.js';
18
+ import { cancelScheduledTask, createScheduledTask, describeSchedule, getSchedulerStore, listScheduledTasks, pauseScheduledTask } from '../core/scheduler.js';
19
+ import { wallTimeAt, workspaceTimeZone } from '../core/time-zone.js';
20
20
  export class WorkflowError extends Error {
21
21
  }
22
22
  /** Checks a plan against the agent it would be added to. */
@@ -316,7 +316,7 @@ async function makePlannedWebhook(plan, agent, agentDir) {
316
316
  webhook: { trigger: publicTrigger(trigger), token, ...(webhook.setup ? { setup: webhook.setup } : {}) },
317
317
  checklist: [{
318
318
  label: `Turn on the ${webhook.name} webhook: paste its URL into ${SENDER_NAMES[webhook.sender]}'s webhook settings, then add the signing secret ${webhook.sender === 'custom' ? 'your app signs with' : 'it shows'}`,
319
- href: `/agents/config?agent=${encodeURIComponent(agent)}&tab=overview`,
319
+ href: `/agents/config?agent=${encodeURIComponent(agent)}&tab=workflows`,
320
320
  }],
321
321
  };
322
322
  }
@@ -342,7 +342,9 @@ export async function createWorkflow(input, deps) {
342
342
  // matter. Its parameters are tidied again, as the plan may have been
343
343
  // edited on the review screen.
344
344
  const { parameters } = planParameters(plan.parameters, { startMessage: plan.startMessage, skillBody: plan.skill.body, scheduled: plan.trigger.kind === 'schedule' });
345
- const markWorkflow = (agentDir) => setWorkflowFields(agentDir, plan.id, { title: plan.name, startMessage: plan.startMessage, trigger: plan.trigger.description, parameters });
345
+ // One text, as the editor shows it: the start message, then the steps.
346
+ const instructions = plan.skill.body.trim().startsWith(plan.startMessage.trim()) ? plan.skill.body : `${plan.startMessage.trim()}\n\n${plan.skill.body.trim()}`;
347
+ const markWorkflow = (agentDir) => setWorkflowFields(agentDir, plan.id, { title: plan.name, instructions, parameters });
346
348
  const agents = await deps.agentInfo();
347
349
  resolveConnections(plan, agents);
348
350
  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()]] : []))) : {};
@@ -491,11 +493,46 @@ function workflowTasks(id, tasks) {
491
493
  .filter((t) => t.workflow_id === id && t.status !== 'cancelled' && t.status !== 'done')
492
494
  .sort((a, b) => Number('every' in b.schedule) - Number('every' in a.schedule));
493
495
  }
496
+ const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
497
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
498
+ /** A schedule in words, in its time zone: "every day at 18:05", "every
499
+ * Monday at 09:00", "every 10 minutes", "once on Nov 6 at 18:05". */
500
+ export function plainSchedule(schedule) {
501
+ const zone = ('time_zone' in schedule && schedule.time_zone) || workspaceTimeZone();
502
+ const at = (iso) => {
503
+ const t = wallTimeAt(Date.parse(iso), zone);
504
+ return { ...t, clock: `${String(t.hour).padStart(2, '0')}:${String(t.minute).padStart(2, '0')}` };
505
+ };
506
+ if ('run_at' in schedule) {
507
+ if (Number.isNaN(Date.parse(schedule.run_at)))
508
+ return `once at ${schedule.run_at}`;
509
+ const t = at(schedule.run_at);
510
+ return `once on ${MONTHS[t.month - 1]} ${t.day} at ${t.clock}`;
511
+ }
512
+ const m = /^(\d+)([mhdw])$/.exec(schedule.every);
513
+ if (!m)
514
+ return describeSchedule(schedule);
515
+ const n = Number(m[1]);
516
+ const unit = m[2];
517
+ const anchor = schedule.anchor && !Number.isNaN(Date.parse(schedule.anchor)) ? at(schedule.anchor) : undefined;
518
+ if (unit === 'm')
519
+ return n === 1 ? 'every minute' : `every ${n} minutes`;
520
+ if (unit === 'h')
521
+ return (n === 1 ? 'every hour' : `every ${n} hours`) + (anchor && n === 1 ? ` at :${String(anchor.minute).padStart(2, '0')}` : '');
522
+ if (unit === 'd')
523
+ return (n === 1 ? 'every day' : `every ${n} days`) + (anchor ? ` at ${anchor.clock}` : '');
524
+ if (n === 1 && anchor)
525
+ return `every ${WEEKDAYS[anchor.weekday]} at ${anchor.clock}`;
526
+ return (n === 1 ? 'every week' : `every ${n} weeks`) + (anchor ? ` on ${WEEKDAYS[anchor.weekday]} at ${anchor.clock}` : '');
527
+ }
494
528
  function scheduleView(task) {
495
529
  return {
496
530
  taskId: task.task_id,
497
531
  status: task.status,
498
532
  repeating: 'every' in task.schedule,
533
+ describe: plainSchedule(task.schedule),
534
+ ...(task.task_name ? { name: task.task_name } : {}),
535
+ notify: Boolean(task.notify),
499
536
  ...(task.params ? { params: task.params } : {}),
500
537
  ...(task.next_run_at ? { nextRunAt: task.next_run_at } : {}),
501
538
  ...(task.last_run_at ? { lastRunAt: task.last_run_at } : {}),
@@ -525,10 +562,12 @@ export async function listAgentWorkflows(agentName, agentDir) {
525
562
  return [];
526
563
  const tasks = await listScheduledTasks({ agent: agentName });
527
564
  const runs = await listRuns(agentName);
565
+ const triggers = await listTriggers(agentName);
528
566
  return workflows.map((w) => {
529
567
  const schedules = workflowTasks(w.id, tasks).map(scheduleView);
530
568
  const recentRuns = runs.filter((r) => r.workflow_id === w.id).slice(0, RECENT_RUNS);
531
- return { ...w, schedules, recentRuns, ...(schedules[0] ? { schedule: schedules[0] } : {}) };
569
+ const webhooks = triggers.filter((t) => t.workflow_id === w.id && t.enabled).length;
570
+ return { ...w, schedules, recentRuns, webhooks, ...(schedules[0] ? { schedule: schedules[0] } : {}) };
532
571
  });
533
572
  }
534
573
  function sameParams(a, b) {
@@ -558,9 +597,11 @@ export async function prepareWorkflowRun(agentName, agentDir, id, rawParams) {
558
597
  }
559
598
  return { workflow, params, message: workflowRunMessage(workflow, params) };
560
599
  }
561
- /** Changes how a workflow's runs start, from the console's workflow
562
- * editor: its title, start message, trigger and parameters. Its steps
563
- * (the skill body) and the skill's other front matter are kept. */
600
+ /** Changes a workflow from the console's editor: its title, its
601
+ * instructions (the skill body, which an older workflow's start message
602
+ * and label are folded into), and its parameters. The skill's other front
603
+ * matter is kept. A body with `start_message` (and no `instructions`) is
604
+ * still taken, as before. */
564
605
  export function updateWorkflow(agentDir, id, input) {
565
606
  if (!readWorkflow(agentDir, id))
566
607
  throw new WorkflowError(`No workflow ${id}.`);
@@ -573,10 +614,13 @@ export function updateWorkflow(agentDir, id, input) {
573
614
  return trimmed;
574
615
  };
575
616
  const title = text(input.title, 'The title', 120);
576
- const startMessage = text(input.start_message, 'The start message', 4000);
577
- if (!startMessage)
578
- throw new WorkflowError('Write the start message: what each run begins with, e.g. "Check the refund request for order $order_number."');
579
- const trigger = text(input.trigger, 'When it runs', 200);
617
+ const instructions = input.instructions === undefined ? undefined : text(input.instructions, 'The instructions', 12000);
618
+ if (instructions !== undefined && !instructions)
619
+ throw new WorkflowError('Write the instructions: what the agent does, e.g. "Check the refund request for order $order_number: look up the order, then…"');
620
+ const startMessage = instructions === undefined ? text(input.start_message, 'The start message', 4000) : '';
621
+ if (instructions === undefined && !startMessage)
622
+ throw new WorkflowError('Write the instructions: what the agent does each run.');
623
+ const trigger = instructions === undefined ? text(input.trigger, 'When it runs', 200) : '';
580
624
  const raw = input.parameters ?? {};
581
625
  if (typeof raw !== 'object' || Array.isArray(raw))
582
626
  throw new WorkflowError('parameters must be an object of names to { description, required }.');
@@ -587,7 +631,7 @@ export function updateWorkflow(agentDir, id, input) {
587
631
  const s = spec && typeof spec === 'object' ? spec : {};
588
632
  parameters[name] = { description: text(s.description, `The description of "${name}"`, 300), required: s.required === true };
589
633
  }
590
- setWorkflowFields(agentDir, id, { ...(title ? { title } : {}), startMessage, ...(trigger ? { trigger } : {}), parameters });
634
+ setWorkflowFields(agentDir, id, instructions !== undefined ? { ...(title ? { title } : {}), instructions, parameters } : { ...(title ? { title } : {}), startMessage, ...(trigger ? { trigger } : {}), parameters });
591
635
  return readWorkflow(agentDir, id);
592
636
  }
593
637
  /** Forgets a workflow: its skill stays, as a plain skill, and the
@@ -327,7 +327,7 @@ ${consoleShellScript}
327
327
 
328
328
  // ---- 3. Done ----
329
329
  function renderDone(c) {
330
- var agentHref = '/agents/config?agent=' + encodeURIComponent(c.agent);
330
+ var agentHref = '/agents/config?agent=' + encodeURIComponent(c.agent) + '&tab=workflows';
331
331
  var items = c.checklist || [];
332
332
  page.innerHTML = '<div class="wf-card">' +
333
333
  '<h2>' + esc(c.workflow) + ' is ready</h2>' +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopengine",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
4
4
  "type": "module",
5
5
  "description": "Runtime for tool-using AI agents with a transparent ReAct loop.",
6
6
  "license": "MIT",