loopengine 0.3.7 → 0.3.9

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.
@@ -31,9 +31,13 @@ export declare function setTelegramWebhook(botToken: string, url: string, secret
31
31
  botUsername?: string;
32
32
  }>;
33
33
  /** Where the bot's updates go now, and the last delivery error Telegram saw. */
34
+ /** Where the bot's updates go now; the last delivery error Telegram saw
35
+ * and when (it keeps it until a newer one); and how many are waiting. */
34
36
  export declare function telegramWebhookInfo(botToken: string): Promise<{
35
37
  ok: boolean;
36
38
  url?: string;
37
39
  lastError?: string;
40
+ lastErrorAt?: string;
41
+ pending?: number;
38
42
  error?: string;
39
43
  }>;
@@ -121,11 +121,20 @@ export async function setTelegramWebhook(botToken, url, secret) {
121
121
  return { ok: true, botUsername: await telegramBotUsername(botToken) };
122
122
  }
123
123
  /** Where the bot's updates go now, and the last delivery error Telegram saw. */
124
+ /** Where the bot's updates go now; the last delivery error Telegram saw
125
+ * and when (it keeps it until a newer one); and how many are waiting. */
124
126
  export async function telegramWebhookInfo(botToken) {
125
127
  const info = await callTelegram(botToken, 'getWebhookInfo', {});
126
128
  if (!info.ok)
127
129
  return { ok: false, error: telegramSetupError(info.description) };
128
- return { ok: true, url: info.result?.url || undefined, ...(info.result?.last_error_message ? { lastError: info.result.last_error_message } : {}) };
130
+ const r = info.result;
131
+ return {
132
+ ok: true,
133
+ url: r?.url || undefined,
134
+ ...(r?.last_error_message ? { lastError: r.last_error_message } : {}),
135
+ ...(r?.last_error_date ? { lastErrorAt: new Date(r.last_error_date * 1000).toISOString() } : {}),
136
+ ...(r?.pending_update_count ? { pending: r.pending_update_count } : {}),
137
+ };
129
138
  }
130
139
  function telegramSetupError(description) {
131
140
  if (/unauthorized|not found/i.test(description ?? ''))
@@ -34,5 +34,9 @@ export declare function toResponsesInput(message: Message): ResponseInputItem[];
34
34
  * blocks, message text, and function calls as tool_use blocks (their
35
35
  * call_id is the id a tool result answers). */
36
36
  export declare function fromResponse(response: OpenAI.Responses.Response): ModelResponse;
37
+ /** OpenAI's reasoning models (GPT-5 on, the o-series), which reason by
38
+ * default and take a reasoning effort; older ones (gpt-4o, gpt-4.1)
39
+ * reject one. */
40
+ export declare function isOpenAIReasoningModel(model: string): boolean;
37
41
  export declare function createOpenAIModelCall(options: OpenAIModelCallOptions): ModelCall;
38
42
  export {};
@@ -232,10 +232,20 @@ export function fromResponse(response) {
232
232
  const usage = response.usage ? { input_tokens: response.usage.input_tokens, output_tokens: response.usage.output_tokens } : undefined;
233
233
  return { stop_reason, content, ...(usage ? { usage } : {}) };
234
234
  }
235
+ /** OpenAI's reasoning models (GPT-5 on, the o-series), which reason by
236
+ * default and take a reasoning effort; older ones (gpt-4o, gpt-4.1)
237
+ * reject one. */
238
+ export function isOpenAIReasoningModel(model) {
239
+ return /^(gpt-(5|6|7|8|9)|o\d)/i.test(model);
240
+ }
235
241
  export function createOpenAIModelCall(options) {
236
242
  const client = options.client ?? new OpenAI({ apiKey: options.apiKey });
243
+ // A reasoning model with no effort set reasons at OpenAI's default
244
+ // anyway (medium), which used to spend the 4096-token cap before it
245
+ // got to a tool call. Unset is low for those: quick, and room to act.
246
+ const reasoningEffort = options.reasoningEffort ?? (isOpenAIReasoningModel(options.model) ? 'low' : undefined);
237
247
  // Reasoning counts toward max_output_tokens.
238
- const maxTokens = options.maxTokens ?? (options.reasoningEffort && options.reasoningEffort !== 'none' ? 16000 : 4096);
248
+ const maxTokens = options.maxTokens ?? (reasoningEffort && reasoningEffort !== 'none' ? 16000 : 4096);
239
249
  return async (messages, system, tools) => {
240
250
  const response = await client.responses.create({
241
251
  model: options.model,
@@ -245,7 +255,7 @@ export function createOpenAIModelCall(options) {
245
255
  tools: tools.length
246
256
  ? tools.map((t) => ({ type: 'function', name: t.name, description: t.description, parameters: t.input_schema, strict: false }))
247
257
  : undefined,
248
- ...(options.reasoningEffort ? { reasoning: { effort: options.reasoningEffort } } : {}),
258
+ ...(reasoningEffort ? { reasoning: { effort: reasoningEffort } } : {}),
249
259
  store: false,
250
260
  include: ['reasoning.encrypted_content'],
251
261
  });
@@ -131,7 +131,10 @@ export function validateWorkflowPlan(raw) {
131
131
  if (!raw || typeof raw !== 'object')
132
132
  return undefined;
133
133
  const r = raw;
134
- const name = str(r.name, 80);
134
+ // A name for people: one written as an id ("daily-hn-digest") becomes
135
+ // words ("Daily hn digest").
136
+ const rawName = str(r.name, 80);
137
+ const name = rawName && /^[a-z0-9]+(-[a-z0-9]+)+$/.test(rawName) ? rawName.charAt(0).toUpperCase() + rawName.slice(1).replace(/-/g, ' ') : rawName;
135
138
  const summary = str(r.summary, 600);
136
139
  const skillRaw = (r.skill ?? {});
137
140
  const skillBody = str(skillRaw.body, 12000);
@@ -408,7 +411,8 @@ export function workflowTemplate(plan) {
408
411
  ...(plan.apps?.length ? { apps: plan.apps } : {}),
409
412
  keys: plan.keys,
410
413
  skills: [{ id: plan.id, description: plan.skill.description, body: plan.skill.body }],
411
- rules: plan.rules.map((r) => ({ name: slugify(`${plan.id}-${r.tool}`, `${plan.id}-rule`), tool: r.tool, decision: r.decision })),
414
+ // Named after the tool: readable, and made unique if taken.
415
+ rules: plan.rules.map((r) => ({ name: r.tool, tool: r.tool, decision: r.decision })),
412
416
  ...(plan.trigger.kind === 'schedule' && plan.trigger.every
413
417
  ? { 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 } : {}) } }
414
418
  : {}),
@@ -26,6 +26,9 @@ export interface Workflow {
26
26
  * `startMessage` (from before) is kept for workflows saved that way. */
27
27
  export interface WorkflowFields {
28
28
  title?: string;
29
+ /** The skill's one-line description: what the job is, which chat
30
+ * matches requests against. Kept as it is when not given. */
31
+ description?: string;
29
32
  instructions?: string;
30
33
  startMessage?: string;
31
34
  trigger?: string;
@@ -97,7 +97,8 @@ function workflowFrom(id, frontmatter, body) {
97
97
  return undefined;
98
98
  return {
99
99
  id,
100
- title: typeof frontmatter.title === 'string' && frontmatter.title.trim() ? frontmatter.title.trim() : titleFromId(id),
100
+ // A title that's just the id (a plan once saved it that way) reads as words.
101
+ title: typeof frontmatter.title === 'string' && frontmatter.title.trim() && frontmatter.title.trim() !== id ? frontmatter.title.trim() : titleFromId(id),
101
102
  description: typeof frontmatter.description === 'string' ? frontmatter.description : '',
102
103
  startMessage,
103
104
  trigger: typeof frontmatter.trigger === 'string' && frontmatter.trigger.trim() ? frontmatter.trigger.trim() : 'When asked',
@@ -141,6 +142,8 @@ export function setWorkflowFields(agentDir, id, fields) {
141
142
  const frontmatter = { ...skill.frontmatter };
142
143
  for (const key of WORKFLOW_KEYS)
143
144
  delete frontmatter[key];
145
+ if (fields.description)
146
+ frontmatter.description = fields.description;
144
147
  const parameters = fields.parameters && Object.keys(fields.parameters).length ? { parameters: fields.parameters } : {};
145
148
  if (fields.instructions !== undefined) {
146
149
  writeSkillFile(path, { ...frontmatter, workflow: true, ...(fields.title ? { title: fields.title } : {}), ...parameters }, fields.instructions);
@@ -23,7 +23,38 @@ export declare function readActauthConfig(agentName: string): ActauthConfigView;
23
23
  /** Appends a new rule. `rule.name` must be unique among this agent's
24
24
  * existing rules — it's the only handle update/removeActauthRule below
25
25
  * have to find it again, so a duplicate would make those ambiguous. */
26
- export declare function addActauthRule(agentName: string, rule: ActauthRuleInput): void;
26
+ /** Adds a rule at the end, or, with `before`, just above that named rule
27
+ * (the first rule that matches a call decides it, so above is what makes
28
+ * it count). */
29
+ export declare function addActauthRule(agentName: string, rule: ActauthRuleInput, options?: {
30
+ before?: string;
31
+ }): void;
32
+ type RuleDecision = 'allow' | 'ask' | 'deny';
33
+ export interface PlannedRule {
34
+ tool: string;
35
+ decision: RuleDecision;
36
+ /** Its name in actauth.yml; the tool's name (made unique) by default. */
37
+ name?: string;
38
+ }
39
+ export interface PlannedRulesResult {
40
+ added: string[];
41
+ /** Tools that already had a rule with the same decision. */
42
+ unchanged: string[];
43
+ /** Tools whose first rule is stricter or looser than planned and was
44
+ * kept as it is: the plan never loosens a decision the agent has. */
45
+ kept: {
46
+ tool: string;
47
+ has: RuleDecision;
48
+ wants: RuleDecision;
49
+ }[];
50
+ }
51
+ /** Gives the planned tools their decisions, once the agent's abilities and
52
+ * apps are installed (their own rules in place), so nothing is added twice:
53
+ * a tool with no rule gets one; one whose first matching rule already says
54
+ * the same is left alone; one a plan wants stricter (ask where an ability
55
+ * allows) gets its rule just above, so it counts; one a plan wants looser
56
+ * keeps what it has, and is reported. */
57
+ export declare function applyPlannedRules(agentName: string, planned: PlannedRule[]): PlannedRulesResult;
27
58
  /** Mutates an existing rule's scope/tool/decision in place (via the
28
59
  * node's own `.set()`, not remove-then-re-add), so any comment attached
29
60
  * to that specific rule in the source YAML survives the edit. */
@@ -34,3 +65,4 @@ export declare function updateActauthRule(agentName: string, ruleName: string, u
34
65
  }): void;
35
66
  export declare function removeActauthRule(agentName: string, ruleName: string): void;
36
67
  export declare function setDefaultDecision(agentName: string, decision: Decision): void;
68
+ export {};
@@ -62,16 +62,53 @@ function findRuleIndex(rules, ruleName) {
62
62
  /** Appends a new rule. `rule.name` must be unique among this agent's
63
63
  * existing rules — it's the only handle update/removeActauthRule below
64
64
  * have to find it again, so a duplicate would make those ambiguous. */
65
- export function addActauthRule(agentName, rule) {
65
+ /** Adds a rule at the end, or, with `before`, just above that named rule
66
+ * (the first rule that matches a call decides it, so above is what makes
67
+ * it count). */
68
+ export function addActauthRule(agentName, rule, options = {}) {
66
69
  const dir = agentDir(agentName);
67
70
  const { path, doc } = loadDoc(dir);
68
71
  const rules = doc.get('rules');
69
72
  if (findRuleIndex(rules, rule.name) !== -1) {
70
73
  throw new ActauthRuleExistsError(`A rule named '${rule.name}' already exists for '${agentName}' — pick a different name or remove it first.`);
71
74
  }
72
- rules.add(doc.createNode({ name: rule.name, scope: rule.scope, tool: rule.tool, decision: rule.decision }));
75
+ const node = doc.createNode({ name: rule.name, scope: rule.scope, tool: rule.tool, decision: rule.decision });
76
+ const at = options.before ? findRuleIndex(rules, options.before) : -1;
77
+ if (at === -1)
78
+ rules.add(node);
79
+ else
80
+ rules.items.splice(at, 0, node);
73
81
  saveDoc(dir, path, doc);
74
82
  }
83
+ const STRICTNESS = { allow: 0, ask: 1, deny: 2 };
84
+ /** Gives the planned tools their decisions, once the agent's abilities and
85
+ * apps are installed (their own rules in place), so nothing is added twice:
86
+ * a tool with no rule gets one; one whose first matching rule already says
87
+ * the same is left alone; one a plan wants stricter (ask where an ability
88
+ * allows) gets its rule just above, so it counts; one a plan wants looser
89
+ * keeps what it has, and is reported. */
90
+ export function applyPlannedRules(agentName, planned) {
91
+ const result = { added: [], unchanged: [], kept: [] };
92
+ for (const rule of planned) {
93
+ const current = readActauthConfig(agentName).rules;
94
+ const first = current.find((r) => r.tool === rule.tool);
95
+ if (first && first.decision === rule.decision) {
96
+ result.unchanged.push(rule.tool);
97
+ continue;
98
+ }
99
+ if (first && STRICTNESS[rule.decision] < STRICTNESS[first.decision]) {
100
+ result.kept.push({ tool: rule.tool, has: first.decision, wants: rule.decision });
101
+ continue;
102
+ }
103
+ const base = rule.name ?? rule.tool;
104
+ let name = base;
105
+ for (let i = 2; current.some((r) => r.name === name); i++)
106
+ name = `${base}-${i}`;
107
+ addActauthRule(agentName, { name, scope: '*/*', tool: rule.tool, decision: rule.decision }, first ? { before: first.name } : {});
108
+ result.added.push(name);
109
+ }
110
+ return result;
111
+ }
75
112
  /** Mutates an existing rule's scope/tool/decision in place (via the
76
113
  * node's own `.set()`, not remove-then-re-add), so any comment attached
77
114
  * to that specific rule in the source YAML survives the edit. */
@@ -49,6 +49,7 @@ export const agentsConfigPageHtml = `<!doctype html>
49
49
  form.add-source .schedule-when input[type="number"] { width: 80px; }
50
50
  form.add-source .schedule-when select, form.add-source .schedule-when input[type="datetime-local"] { width: auto; }
51
51
  .workflow-card { margin-bottom: 10px; }
52
+ .workflow-card.workflow-focus { box-shadow: 0 0 0 2px var(--le-accent, #0f766e); transition: box-shadow 0.3s; }
52
53
  tr.schedule-focus td { background: var(--le-accent-soft, #e6f4ef); transition: background 0.3s; }
53
54
  .workflow-starts { margin: 10px 0 0; padding: 10px 12px; border: 1px solid var(--le-line, #e5e7eb); border-radius: 8px; background: var(--le-surface, #f6f7f9); }
54
55
  .workflow-starts h4 { margin: 0; font-size: 13.5px; font-weight: 600; }
@@ -437,10 +438,6 @@ ${consoleShellScript}
437
438
  return attempt().catch(function () { return attempt(); });
438
439
  }
439
440
 
440
- function badge(label) {
441
- return '<span class="badge ' + (label === 'custom' ? 'badge-custom' : 'badge-default') + '">' + escapeHtml(label) + '</span>';
442
- }
443
-
444
441
  // A small, deliberately non-CommonMark markdown-to-HTML renderer for
445
442
  // the skill Body field's Preview toggle (see wireSkillsHandlers) — this
446
443
  // page stays a self-contained string, no build step, no CDN (same
@@ -837,6 +834,35 @@ ${consoleShellScript}
837
834
  var workflowsRefreshTimer = null;
838
835
  // Set by a workflow's "Add a schedule", for the Schedules tab's form.
839
836
  var pendingScheduleWorkflow = null;
837
+ // Set by a schedule's workflow link: the card to scroll to and mark.
838
+ var pendingWorkflowFocus = null;
839
+
840
+ function focusWorkflowCard(tries) {
841
+ if (!pendingWorkflowFocus) return;
842
+ var card = detail.querySelector('.workflow-card[data-workflow="' + CSS.escape(pendingWorkflowFocus) + '"]');
843
+ if (!card) {
844
+ if ((tries || 0) < 40) setTimeout(function () { focusWorkflowCard((tries || 0) + 1); }, 150);
845
+ return;
846
+ }
847
+ pendingWorkflowFocus = null;
848
+ card.scrollIntoView({ block: 'start' });
849
+ card.classList.add('workflow-focus');
850
+ setTimeout(function () { card.classList.remove('workflow-focus'); }, 2500);
851
+ }
852
+
853
+ // Set by a workflow's Edit on the Skills list: the workflow to open in
854
+ // its editor once the Workflows tab has drawn it.
855
+ var pendingWorkflowEdit = null;
856
+
857
+ function openPendingWorkflowEdit() {
858
+ if (!pendingWorkflowEdit) return;
859
+ var card = detail.querySelector('.workflow-card[data-workflow="' + CSS.escape(pendingWorkflowEdit) + '"]');
860
+ if (!card) return;
861
+ pendingWorkflowEdit = null;
862
+ openWorkflowEditor(currentName, card);
863
+ card.scrollIntoView({ block: 'start' });
864
+ }
865
+
840
866
  // Set by a schedule on a workflow card (or a #schedule-<id> link): the
841
867
  // Schedules tab's row to show.
842
868
  var pendingScheduleFocus = /^#schedule-/.test(location.hash) ? location.hash.slice('#schedule-'.length) : null;
@@ -869,11 +895,13 @@ ${consoleShellScript}
869
895
  if (!r.ok) { el.innerHTML = '<p class="error">Could not load workflows: ' + escapeHtml(r.body.error || 'unknown error') + '</p>'; return; }
870
896
  var workflows = r.body.workflows || [];
871
897
  fillWorkflowsCard(workflows);
898
+ leShell.setCount('workflows', workflows.length);
872
899
  workflowsById = {};
873
900
  workflows.forEach(function (w) { workflowsById[w.id] = w; });
874
901
  el.innerHTML = renderWorkflowsHtml(name, workflows);
875
902
  var cards = el.querySelectorAll('.workflow-card');
876
903
  for (var i = 0; i < cards.length; i++) wireWorkflowCard(name, cards[i]);
904
+ openPendingWorkflowEdit();
877
905
  // Keep a running run's status current while the Overview is open.
878
906
  var running = workflows.some(function (w) { return (w.recentRuns || []).some(function (x) { return x.status === 'running'; }); });
879
907
  if (running && currentTab === 'workflows') workflowsRefreshTimer = setTimeout(function () { if (!workflowEditing) loadWorkflowsSection(name); }, 3000);
@@ -893,7 +921,10 @@ ${consoleShellScript}
893
921
  function workflowEditorHtml(w) {
894
922
  var rows = Object.keys(w.parameters || {}).map(function (p) { return workflowParamRowHtml(p, w.parameters[p]); }).join('');
895
923
  return '<form class="add-source workflow-editor-form" style="margin-top:10px">' +
896
- '<label>Title<input name="title" maxlength="120" value="' + escapeHtml(w.title) + '"></label>' +
924
+ '<label>Title<input name="title" maxlength="120" value="' + escapeHtml(w.title) + '">' +
925
+ '<span class="hint">ID: <code>' + escapeHtml(w.id) + '</code>, used by the API, schedules and webhooks; it doesn' + String.fromCharCode(8217) + 't change when you edit.</span></label>' +
926
+ '<label>Description <span class="hint">(one line on what this job is; the agent uses it to recognise the job when you ask in Chat)</span>' +
927
+ '<input name="description" maxlength="500" required value="' + escapeHtml(w.description) + '"></label>' +
897
928
  '<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>' +
898
929
  '<textarea name="instructions" maxlength="12000" rows="12" required>' + escapeHtml(w.instructions) + '</textarea></label>' +
899
930
  '<div class="body-field">Parameters <span class="hint">(values a run is given; apps send them as params)</span>' +
@@ -960,7 +991,7 @@ ${consoleShellScript}
960
991
  result.textContent = 'Saving' + String.fromCharCode(8230);
961
992
  fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/workflows/' + encodeURIComponent(w.id), {
962
993
  method: 'PUT', headers: { 'content-type': 'application/json' },
963
- body: JSON.stringify({ title: form.title.value, instructions: form.instructions.value, parameters: parameters }),
994
+ body: JSON.stringify({ title: form.title.value, description: form.description.value, instructions: form.instructions.value, parameters: parameters }),
964
995
  }).then(function (r) {
965
996
  if (!r.ok) { result.textContent = r.body.error || 'Could not save'; return; }
966
997
  workflowEditing = false;
@@ -1190,7 +1221,11 @@ ${consoleShellScript}
1190
1221
  var action = btn.dataset.action;
1191
1222
  if (action === 'cancel' && btn.dataset.confirming !== '1') { btn.dataset.confirming = '1'; btn.textContent = 'Click again to remove'; return; }
1192
1223
  btn.disabled = true;
1193
- fetchJsonRetryOnce('/schedules/' + encodeURIComponent(btn.dataset.id) + '/' + action, { method: 'POST' }).then(function () { loadWorkflowsSection(name); });
1224
+ fetchJsonRetryOnce('/schedules/' + encodeURIComponent(btn.dataset.id) + '/' + action, { method: 'POST' }).then(function () {
1225
+ loadWorkflowsSection(name);
1226
+ // The Schedules tab's count, and the Overview's tile.
1227
+ fillOverviewCards(name);
1228
+ });
1194
1229
  });
1195
1230
  }
1196
1231
  card.querySelector('.workflow-run').addEventListener('click', function (ev) {
@@ -1243,6 +1278,7 @@ ${consoleShellScript}
1243
1278
  var waiting = active.filter(function (t) { return t.status === 'awaiting_approval'; }).length;
1244
1279
  var next = active.map(function (t) { return t.next_run_at; }).filter(Boolean).sort()[0];
1245
1280
  set('ovSchedulesValue', String(active.length));
1281
+ leShell.setCount('schedules', active.length);
1246
1282
  var schedulesDetail = [];
1247
1283
  if (waiting) schedulesDetail.push(waiting + ' waiting for approval');
1248
1284
  if (next) schedulesDetail.push('Next run ' + escapeHtml(new Date(next).toLocaleString()));
@@ -1276,7 +1312,10 @@ ${consoleShellScript}
1276
1312
  '<dt>Provider</dt><dd>' + escapeHtml(cfg.model.provider) + '</dd>' +
1277
1313
  '<dt>Model</dt><dd>' + escapeHtml(cfg.model.model || '(provider default)') + '</dd>' +
1278
1314
  '<dt>Max tokens</dt><dd>' + escapeHtml(cfg.model.maxTokens != null ? cfg.model.maxTokens : '(default)') + '</dd>' +
1279
- '<dt>Reasoning effort</dt><dd>' + escapeHtml(cfg.model.reasoningEffort || (REASONING_PROVIDERS.indexOf(cfg.model.provider) !== -1 ? '(model default)' : 'n/a')) + '</dd>' +
1315
+ // Unset is low for OpenAI's reasoning models (core/model-calls/openai-model-call.ts).
1316
+ '<dt>Reasoning effort</dt><dd>' + escapeHtml(cfg.model.reasoningEffort ||
1317
+ (cfg.model.provider === 'openai' && /^(gpt-[5-9]|o[0-9])/i.test(cfg.model.model || '') ? 'low (default)'
1318
+ : REASONING_PROVIDERS.indexOf(cfg.model.provider) !== -1 ? '(model default)' : 'n/a')) + '</dd>' +
1280
1319
  '</dl>' +
1281
1320
  '<form class="add-source" id="modelForm" style="display:none">' +
1282
1321
  '<label>Provider' +
@@ -1292,7 +1331,7 @@ ${consoleShellScript}
1292
1331
  '<label>Max tokens <span class="hint">(blank for provider default)</span>' +
1293
1332
  '<input type="number" name="maxTokens" min="1" step="1" value="' + (cfg.model.maxTokens != null ? escapeHtml(cfg.model.maxTokens) : '') + '">' +
1294
1333
  '</label>' +
1295
- '<label id="reasoningEffortField">Reasoning effort <span class="hint">(how much the model thinks before answering: none turns it off, unset uses the model\\\'s default)</span>' +
1334
+ '<label id="reasoningEffortField">Reasoning effort <span class="hint">(how much the model thinks before answering: none turns it off, unset uses the model\\\'s default, and low for OpenAI\\\'s reasoning models)</span>' +
1296
1335
  '<select name="reasoningEffort">' +
1297
1336
  ['', 'none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'].map(function (v) {
1298
1337
  return '<option value="' + v + '"' + (v === (cfg.model.reasoningEffort || '') ? ' selected' : '') + '>' + (v || '(unset)') + '</option>';
@@ -1308,11 +1347,6 @@ ${consoleShellScript}
1308
1347
  return renderOverviewCards(cfg) +
1309
1348
  '<section>' + systemPromptSection + '</section>' +
1310
1349
  '<section>' + modelSection + '</section>' +
1311
- '<section><h3>Hooks</h3><dl class="kv">' +
1312
- '<dt>sessionIdFor</dt><dd>' + badge(cfg.sessionIdFor.indexOf('custom') === 0 ? 'custom' : 'default') + ' <span class="muted">' + escapeHtml(cfg.sessionIdFor) + '</span></dd>' +
1313
- '<dt>tenantFor</dt><dd>' + badge(cfg.tenantFor.indexOf('custom') === 0 ? 'custom' : 'default') + ' <span class="muted">' + escapeHtml(cfg.tenantFor) + '</span></dd>' +
1314
- '<dt>isSafeTool</dt><dd>' + badge(cfg.isSafeTool === 'custom' ? 'custom' : 'default') + ' <span class="muted">' + escapeHtml(cfg.isSafeTool) + '</span></dd>' +
1315
- '</dl></section>' +
1316
1350
  '<section><h3>Limits &amp; budgets <button type="button" class="edit-btn" id="editLimitsBtn">Edit</button></h3>' +
1317
1351
  '<dl class="kv" id="limitsDisplay">' +
1318
1352
  '<dt>maxTurns</dt><dd>' + escapeHtml(cfg.maxTurns) + '</dd>' +
@@ -1333,7 +1367,6 @@ ${consoleShellScript}
1333
1367
  '<button type="button" id="cancelLimitsBtn">Cancel</button>' +
1334
1368
  '<div class="error" id="limitsError"></div>' +
1335
1369
  '</form>' +
1336
- '<dl class="kv"><dt>skillsDirs</dt><dd>' + escapeHtml(cfg.skillsDirs.join(', ') || '(none)') + '</dd></dl>' +
1337
1370
  '</section>';
1338
1371
  }
1339
1372
 
@@ -1922,7 +1955,10 @@ ${consoleShellScript}
1922
1955
  (saved ? '<button type="button" class="delete-btn notifier-remove"' + (locked ? ' disabled' : '') + '>Remove</button>' : '') + '</div>' +
1923
1956
  (isWebhook ? '<p class="hint" style="margin:0">A webhook always sends once saved, alongside the primary messaging app.</p>' : '') +
1924
1957
  '<div class="hint notifier-result"></div>' +
1925
- '</form></details>';
1958
+ '</form>' +
1959
+ // Who can decide and give jobs here: part of this app's settings.
1960
+ (channel === 'slack' || channel === 'telegram' ? renderApproversHtml(n, channel) : '') +
1961
+ '</details>';
1926
1962
  }
1927
1963
 
1928
1964
  function renderConnectionsHtml(n) {
@@ -1977,7 +2013,7 @@ ${consoleShellScript}
1977
2013
  '<td><button type="button" class="delete-btn approver-remove" data-user="' + escapeHtml(a.user) + '"' + (n.canSave === false ? ' disabled' : '') + '>Remove</button></td></tr>';
1978
2014
  }).join('');
1979
2015
  var where = app === 'telegram' ? 'by replying to it' : 'by replying in its thread';
1980
- return '<section class="approvers-section" data-app="' + app + '"><h3>' + title + ' approvers</h3>' +
2016
+ return '<section class="approvers-section" data-app="' + app + '" style="max-width:560px;margin-top:18px;padding-top:14px;border-top:1px solid var(--le-line, #e5e7eb)"><h4 style="margin:0 0 6px">Approvers</h4>' +
1981
2017
  (n.primary !== app ? '<p class="hint" style="margin:0 0 8px">' + title + ' isn&#39;t primary, so requests aren&#39;t sent there until it is. Jobs started there still use this list.</p>' : '') +
1982
2018
  '<p class="muted" style="margin:0 0 10px">People who can approve, deny, change or answer a request ' + where + ', like &ldquo;yes, go ahead&rdquo; or &ldquo;make it $40&rdquo;. A plain &ldquo;yes&rdquo; runs it; a reply that&#39;s less clear (&ldquo;sure, send it&rdquo;, &ldquo;yes but check the amount&rdquo;) shows exactly what will run with a Confirm button first, and a change is shown to approve first. Once anyone is listed, only approvers&#39; button clicks count, and only approvers can give the agent jobs. Anyone can still ask about a request.</p>' +
1983
2019
  (approvers.length
@@ -2067,8 +2103,7 @@ ${consoleShellScript}
2067
2103
  '<p class="muted" style="margin:0 0 12px">' + renderNotifierStatus(n) + '</p>' +
2068
2104
  (n.error ? '<p class="error">' + escapeHtml(n.error) + '</p>' : '') +
2069
2105
  (n.source === 'code' ? '' : renderConnectionsHtml(n)) + '</section>' +
2070
- renderApproversHtml(n, 'slack') +
2071
- renderApproversHtml(n, 'telegram') +
2106
+
2072
2107
  '<section><h3>Recent deliveries</h3>' + renderDeliveries(n.deliveries || []) + '</section>';
2073
2108
  }
2074
2109
 
@@ -2217,8 +2252,16 @@ ${consoleShellScript}
2217
2252
  fetchJsonRetryOnce(url).then(function (r) {
2218
2253
  if (!r.ok) { out.textContent = r.body.error || 'Could not check'; return; }
2219
2254
  var b = r.body;
2255
+ // Telegram keeps its last error until a newer one, so it says when.
2256
+ var when = '';
2257
+ if (b.lastErrorAt) {
2258
+ var mins = Math.max(0, Math.round((Date.now() - Date.parse(b.lastErrorAt)) / 60000));
2259
+ when = mins < 2 ? 'just now' : mins < 120 ? mins + ' minutes ago' : mins < 2880 ? Math.round(mins / 60) + ' hours ago' : Math.round(mins / 1440) + ' days ago';
2260
+ when = ' (' + when + ', ' + new Date(b.lastErrorAt).toLocaleString() + ')';
2261
+ }
2220
2262
  out.textContent = (b.pointsHere ? 'Connected' + (b.botUsername ? ' (@' + b.botUsername + ')' : '') + '.' : b.url ? 'The bot sends its updates somewhere else: ' + b.url + '. Connect again to point it here.' : 'The bot isn' + String.fromCharCode(8217) + 't sending its updates anywhere. Connect again.') +
2221
- (b.lastError ? ' Telegram' + String.fromCharCode(8217) + 's last delivery error: ' + b.lastError : '');
2263
+ (b.pending ? ' ' + b.pending + ' update' + (b.pending === 1 ? '' : 's') + ' waiting to be delivered.' : '') +
2264
+ (b.lastError ? ' Telegram' + String.fromCharCode(8217) + 's last delivery error' + when + ': ' + b.lastError + (b.pointsHere && !b.pending ? ' Nothing is waiting, so it' + String.fromCharCode(8217) + 's likely from before and since fixed.' : '') : '');
2222
2265
  });
2223
2266
  });
2224
2267
  }
@@ -2381,7 +2424,8 @@ ${consoleShellScript}
2381
2424
  return '<tr id="schedule-' + escapeHtml(t.task_id) + '">' +
2382
2425
  '<td style="min-width:180px"><strong>' + escapeHtml(t.task_name || 'Untitled') + '</strong>' +
2383
2426
  (t.workflow
2384
- ? '<div><span class="le-pill accent">Workflow</span> ' + escapeHtml(t.workflow.title) + '</div><div class="schedule-result hint">' + escapeHtml(t.workflow.start_message) +
2427
+ ? '<div><span class="le-pill accent">Workflow</span> <a href="?agent=' + encodeURIComponent(currentName) + '&amp;tab=workflows" class="schedule-workflow-link" data-workflow="' + escapeHtml(t.workflow.id) + '" title="See it on the Workflows tab">' + escapeHtml(t.workflow.title) + '</a></div>' +
2428
+ '<div class="schedule-result hint">' + escapeHtml(t.workflow.description || t.workflow.start_message) +
2385
2429
  (t.params ? '<br>' + Object.keys(t.params).map(function (k) { return escapeHtml(k) + ': ' + escapeHtml(t.params[k]); }).join(' ' + String.fromCharCode(183) + ' ') : '') + '</div>'
2386
2430
  : t.workflow_missing
2387
2431
  ? '<div><span class="le-pill bad">Workflow removed</span> <span class="hint">' + escapeHtml(t.workflow_id) + '</span></div>'
@@ -2455,6 +2499,17 @@ ${consoleShellScript}
2455
2499
  function wireSchedulesHandlers(name) {
2456
2500
  var content = schedulesContentEl();
2457
2501
  if (!content) return;
2502
+ // A schedule's workflow opens its card on the Workflows tab.
2503
+ var workflowLinks = content.querySelectorAll('.schedule-workflow-link');
2504
+ for (var wl = 0; wl < workflowLinks.length; wl++) {
2505
+ workflowLinks[wl].addEventListener('click', function (ev) {
2506
+ if (ev.metaKey || ev.ctrlKey || ev.shiftKey) return;
2507
+ ev.preventDefault();
2508
+ pendingWorkflowFocus = ev.currentTarget.dataset.workflow;
2509
+ switchTab('workflows');
2510
+ focusWorkflowCard();
2511
+ });
2512
+ }
2458
2513
  var actionBtns = content.querySelectorAll('.schedule-action');
2459
2514
  for (var i = 0; i < actionBtns.length; i++) {
2460
2515
  actionBtns[i].addEventListener('click', function (ev) {
@@ -3544,12 +3599,17 @@ ${consoleShellScript}
3544
3599
  // needs a further per-skill GET, to pull the body that index doesn't
3545
3600
  // carry. ----
3546
3601
 
3602
+ // A skill that's a workflow is labelled, and its Edit opens the workflow
3603
+ // editor: its instructions are one text there, with its title,
3604
+ // description and parameters.
3547
3605
  function renderSkillCard(s) {
3548
3606
  return '<div class="source">' +
3549
3607
  '<div class="source-head">' +
3550
- '<h4>' + escapeHtml(s.name) + '</h4>' +
3608
+ '<h4>' + escapeHtml(s.name) + (s.workflow ? ' <span class="le-pill accent">Workflow</span>' : '') + '</h4>' +
3551
3609
  '<span>' +
3552
- '<button type="button" class="edit-skill-btn" data-id="' + escapeHtml(s.name) + '">Edit</button> ' +
3610
+ (s.workflow
3611
+ ? '<button type="button" class="edit-workflow-skill-btn" data-id="' + escapeHtml(s.name) + '" title="Edit it as a workflow, on the Workflows tab">Edit</button> '
3612
+ : '<button type="button" class="edit-skill-btn" data-id="' + escapeHtml(s.name) + '">Edit</button> ') +
3553
3613
  '<button type="button" class="delete-btn delete-skill-btn" data-id="' + escapeHtml(s.name) + '" title="Delete this skill" aria-label="Delete this skill">Delete</button>' +
3554
3614
  '</span>' +
3555
3615
  '</div>' +
@@ -3559,11 +3619,7 @@ ${consoleShellScript}
3559
3619
 
3560
3620
  function renderSkillsTabHtml(cfg) {
3561
3621
  var listHtml = cfg.skills.length ? cfg.skills.map(renderSkillCard).join('') : '<p class="hint">No skills yet.</p>';
3562
- return '<section><h3>Skills dirs</h3><dl class="kv">' +
3563
- '<dt>skillsDirs</dt><dd>' + escapeHtml(cfg.skillsDirs.join(', ') || '(none)') + '</dd>' +
3564
- '<dt>skillIndexBudgetTokens</dt><dd>' + escapeHtml(cfg.skillIndexBudgetTokens) + '</dd>' +
3565
- '</dl></section>' +
3566
- '<section><h3>Skills (' + cfg.skills.length + ')</h3><div id="skillList">' + listHtml + '</div></section>' +
3622
+ return '<section><h3>Skills (' + cfg.skills.length + ')</h3><div id="skillList">' + listHtml + '</div></section>' +
3567
3623
  '<section><h3 id="skillFormHeading">Add a skill</h3>' +
3568
3624
  '<form class="add-source" id="skillForm">' +
3569
3625
  '<label>Id <span class="hint">(lowercase, hyphen-separated — becomes the SKILL.md folder name; nested skills aren\\'t editable here)</span>' +
@@ -3657,6 +3713,15 @@ ${consoleShellScript}
3657
3713
  });
3658
3714
  }
3659
3715
 
3716
+ var workflowEditButtons = panel.querySelectorAll('.edit-workflow-skill-btn');
3717
+ for (var we = 0; we < workflowEditButtons.length; we++) {
3718
+ workflowEditButtons[we].addEventListener('click', function (ev) {
3719
+ pendingWorkflowEdit = ev.currentTarget.getAttribute('data-id');
3720
+ switchTab('workflows');
3721
+ openPendingWorkflowEdit();
3722
+ });
3723
+ }
3724
+
3660
3725
  var editButtons = panel.querySelectorAll('.edit-skill-btn');
3661
3726
  for (var i = 0; i < editButtons.length; i++) {
3662
3727
  editButtons[i].addEventListener('click', function (ev) {
@@ -4441,7 +4506,7 @@ ${consoleShellScript}
4441
4506
  prompt: cfg.systemPrompt,
4442
4507
  chips: [modelChip(cfg), { text: 'unmatched calls: ' + cfg.permissions.defaultDecision }],
4443
4508
  tab: currentTab,
4444
- counts: { tools: cfg.tools.length, skills: cfg.skills.length, abilities: cfg.abilityCount, actauth: cfg.permissions.rules.length },
4509
+ counts: { tools: cfg.tools.length, skills: cfg.skills.length, abilities: cfg.abilityCount, workflows: cfg.skills.filter(function (s) { return s.workflow; }).length },
4445
4510
  }) +
4446
4511
  '<div class="le-scroll"><div class="le-page" style="display:block">' +
4447
4512
  '<div class="tab-panel" data-tab-panel="overview">' + renderOverviewHtml(cfg) + '</div>' +
@@ -56,8 +56,8 @@ 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'], ['workflows', 'Workflows'], ['abilities', 'Apps'], ['actauth', 'Permissions'],
60
- ['messaging', 'Messaging'], ['schedules', 'Schedules'], ['env', 'Environment'], ['advanced', 'Advanced'], ['chat', 'Chat']
59
+ ['overview', 'Overview'], ['workflows', 'Workflows'], ['abilities', 'Apps'], ['chat', 'Chat'],
60
+ ['messaging', 'Messaging'], ['schedules', 'Schedules'], ['actauth', 'Permissions'], ['env', 'Environment'], ['advanced', 'Advanced']
61
61
  ];
62
62
 
63
63
  function esc(s) {
@@ -127,6 +127,8 @@ export declare function setupAgentTelegramWebhook(config: AgentConfig, baseUrl:
127
127
  export declare function agentTelegramWebhookInfo(config: AgentConfig): Promise<{
128
128
  url?: string;
129
129
  lastError?: string;
130
+ lastErrorAt?: string;
131
+ pending?: number;
130
132
  pointsHere: boolean;
131
133
  botUsername?: string;
132
134
  }>;
@@ -161,7 +161,7 @@ export function jobAllowed(config, app, message) {
161
161
  const home = app === 'slack' ? file?.channelId : file?.chatId;
162
162
  if (!message.direct && home && home === message.conversation)
163
163
  return { allowed: true };
164
- return { allowed: false, message: `Jobs by direct message need approvers: add yourself in this agent's Messaging tab in LoopEngine (${APP_NAMES[app]} section).` };
164
+ return { allowed: false, message: `Jobs by direct message need approvers: in LoopEngine, open this agent's Messaging tab, then ${APP_NAMES[app]} → Approvers, and add yourself.` };
165
165
  }
166
166
  /** Replaces the approver list on the agent's saved connection for `app`,
167
167
  * primary or not. Throws NotifierFileError when there's no connection
@@ -224,6 +224,8 @@ export async function agentTelegramWebhookInfo(config) {
224
224
  return {
225
225
  ...(info.url ? { url: info.url } : {}),
226
226
  ...(info.lastError ? { lastError: info.lastError } : {}),
227
+ ...(info.lastErrorAt ? { lastErrorAt: info.lastErrorAt } : {}),
228
+ ...(info.pending ? { pending: info.pending } : {}),
227
229
  pointsHere: Boolean(info.url?.endsWith(`/hooks/telegram/${encodeURIComponent(config.name)}`)),
228
230
  ...(botUsername ? { botUsername } : {}),
229
231
  };
@@ -1433,7 +1433,7 @@ ${consoleShellScript}
1433
1433
  var counts = {};
1434
1434
  if (cfg) {
1435
1435
  chips.push({ text: typeof cfg.model === 'string' ? cfg.model : cfg.model.provider + ' \u00b7 ' + (cfg.model.model || 'default model'), mono: true });
1436
- counts = { tools: cfg.tools.length, skills: cfg.skills.length, abilities: cfg.abilityCount, actauth: cfg.permissions.rules.length };
1436
+ counts = { tools: cfg.tools.length, skills: cfg.skills.length, abilities: cfg.abilityCount };
1437
1437
  }
1438
1438
  head.innerHTML = leShell.renderHead({ name: name, prompt: agent.systemPrompt, chips: chips, tab: 'chat', counts: counts });
1439
1439
  }
@@ -11,7 +11,7 @@
11
11
  import { createScheduledTask, pauseScheduledTask } from '../core/scheduler.js';
12
12
  import { workspaceTimeZone } from '../core/time-zone.js';
13
13
  import { STARTER_TEMPLATES, starterTemplate, templateScheduleStart } from '../core/system-agents/templates.js';
14
- import { ActauthRuleExistsError, addActauthRule, setDefaultDecision } from './actauth-admin.js';
14
+ import { ActauthRuleExistsError, addActauthRule, applyPlannedRules, setDefaultDecision } from './actauth-admin.js';
15
15
  import { setAgentEnvVar } from './env-admin.js';
16
16
  import { freeAgentName } from './interview-admin.js';
17
17
  import { writeSkill } from './skills-admin.js';
@@ -126,19 +126,9 @@ export async function createFromTemplate(id, deps) {
126
126
  export async function createFromTemplateObject(template, deps) {
127
127
  const { candidate, setup } = template;
128
128
  const name = freeAgentName(candidate.agent.name, deps.agentExists);
129
- // 1. The agent, and its permissions: everything asks unless a rule says
130
- // otherwise, and the template's rules come first.
129
+ // 1. The agent: everything asks unless a rule says otherwise.
131
130
  await deps.scaffoldAgent(name, candidate.agent.systemPrompt);
132
131
  setDefaultDecision(name, 'ask');
133
- for (const rule of setup.rules) {
134
- try {
135
- addActauthRule(name, { name: rule.name, scope: '*/*', tool: rule.tool, decision: rule.decision });
136
- }
137
- catch (err) {
138
- if (!(err instanceof ActauthRuleExistsError))
139
- throw err;
140
- }
141
- }
142
132
  // 2. Abilities: their tools, skills and rules. One failing doesn't stop
143
133
  // the rest; it heads the checklist.
144
134
  const installed = [];
@@ -185,6 +175,9 @@ export async function createFromTemplateObject(template, deps) {
185
175
  failed.push({ name: app.title, error: installFailureReason(err) });
186
176
  }
187
177
  }
178
+ // The template's rules, now that abilities and apps have added theirs:
179
+ // none twice, a stricter one placed so it counts, none loosened.
180
+ applyPlannedRules(name, setup.rules.map((r) => ({ name: r.name, tool: r.tool, decision: r.decision })));
188
181
  // 3. Its own skills and non-secret settings.
189
182
  for (const skill of setup.skills ?? [])
190
183
  writeSkill(name, skill.id, { description: skill.description, body: skill.body });
@@ -6,11 +6,11 @@
6
6
  // one without changing anything that agent already does.
7
7
  import { join } from 'node:path';
8
8
  import { addGatewayTool, readGatewayToolsFromDir } from '../core/gateway-tools.js';
9
- import { addActauthRule, readActauthConfig } from './actauth-admin.js';
9
+ import { applyPlannedRules } 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 { planParameters, planWebhook, planWorkflowWithModel, slugify, workflowTemplate } from '../core/system-agents/workflow-planner.js';
13
+ import { planParameters, planWebhook, planWorkflowWithModel, workflowTemplate } from '../core/system-agents/workflow-planner.js';
14
14
  import { createTrigger, getTriggerStore, 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';
@@ -375,18 +375,7 @@ export async function createWorkflow(input, deps) {
375
375
  if (!agent)
376
376
  throw new WorkflowError(`There's no agent named ${target.agent ?? '(none)'}.`);
377
377
  const check = checkWorkflowForAgent(plan, agent);
378
- // 1. Rules: only for tools the agent has no rule for.
379
- const { rules: current } = readActauthConfig(agent.name);
380
- for (const rule of plan.rules) {
381
- if (current.some((r) => r.tool === rule.tool))
382
- continue;
383
- let name = slugify(`${plan.id}-${rule.tool}`, `${plan.id}-rule`);
384
- for (let i = 2; current.some((r) => r.name === name); i++)
385
- name = `${slugify(`${plan.id}-${rule.tool}`)}-${i}`;
386
- addActauthRule(agent.name, { name, scope: '*/*', tool: rule.tool, decision: rule.decision });
387
- current.push({ name, scope: '*/*', tool: rule.tool, decision: rule.decision });
388
- }
389
- // 2. Abilities it doesn't have yet.
378
+ // 1. Abilities it doesn't have yet (their rules come with them).
390
379
  const installed = [];
391
380
  const failed = [...mcp.failed];
392
381
  for (const spec of check.toInstall) {
@@ -441,6 +430,10 @@ export async function createWorkflow(input, deps) {
441
430
  const connected = copyConnectedTools(plan, agent.name, agents, deps.agentDir);
442
431
  installed.push(...connected.copied);
443
432
  failed.push(...connected.failed);
433
+ // 2. Rules, now that what was installed has added its own: none twice,
434
+ // a stricter one (ask where an ability allows) placed so it counts,
435
+ // and a decision the agent already has never loosened.
436
+ const ruled = applyPlannedRules(agent.name, plan.rules);
444
437
  // 3. Its steps, as a skill; then load the agent again with all of it.
445
438
  writeSkill(agent.name, plan.id, { description: plan.skill.description, body: plan.skill.body });
446
439
  let loadError;
@@ -477,7 +470,7 @@ export async function createWorkflow(input, deps) {
477
470
  newAgent: false,
478
471
  workflow: plan.name,
479
472
  abilities: { installed, failed },
480
- keptDecisions: check.conflicts,
473
+ keptDecisions: ruled.kept,
481
474
  checklist: [...withMissingSteps(plan, agent.name, templateChecklist(template, agent.name, deps, failed, appsToConnect)), ...hook.checklist],
482
475
  ...(hook.webhook ? { webhook: hook.webhook } : {}),
483
476
  ...(hook.webhookError ? { webhookError: hook.webhookError } : {}),
@@ -614,6 +607,9 @@ export function updateWorkflow(agentDir, id, input) {
614
607
  return trimmed;
615
608
  };
616
609
  const title = text(input.title, 'The title', 120);
610
+ const description = input.description === undefined ? undefined : text(input.description, 'The description', 500);
611
+ if (description !== undefined && !description)
612
+ throw new WorkflowError('Write a one-line description: what this job is, e.g. "Daily digest of AI-agent stories on Hacker News". The agent uses it to recognise the job in Chat.');
617
613
  const instructions = input.instructions === undefined ? undefined : text(input.instructions, 'The instructions', 12000);
618
614
  if (instructions !== undefined && !instructions)
619
615
  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…"');
@@ -631,7 +627,8 @@ export function updateWorkflow(agentDir, id, input) {
631
627
  const s = spec && typeof spec === 'object' ? spec : {};
632
628
  parameters[name] = { description: text(s.description, `The description of "${name}"`, 300), required: s.required === true };
633
629
  }
634
- setWorkflowFields(agentDir, id, instructions !== undefined ? { ...(title ? { title } : {}), instructions, parameters } : { ...(title ? { title } : {}), startMessage, ...(trigger ? { trigger } : {}), parameters });
630
+ const named = { ...(title ? { title } : {}), ...(description ? { description: description.replace(/\s+/g, ' ') } : {}) };
631
+ setWorkflowFields(agentDir, id, instructions !== undefined ? { ...named, instructions, parameters } : { ...named, startMessage, ...(trigger ? { trigger } : {}), parameters });
635
632
  return readWorkflow(agentDir, id);
636
633
  }
637
634
  /** Removes a workflow: its skill stays, as a plain skill the agent can
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopengine",
3
- "version": "0.3.7",
3
+ "version": "0.3.9",
4
4
  "type": "module",
5
5
  "description": "Runtime for tool-using AI agents with a transparent ReAct loop.",
6
6
  "license": "MIT",