loopengine 0.1.98 → 0.2.0

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.
Files changed (35) hide show
  1. package/dist/bin/ability-manager.d.ts +35 -2
  2. package/dist/bin/ability-manager.js +168 -35
  3. package/dist/core/ability-details.js +11 -0
  4. package/dist/core/mcp-abilities.d.ts +91 -0
  5. package/dist/core/mcp-abilities.js +400 -0
  6. package/dist/core/mcp-ability-generator.d.ts +59 -0
  7. package/dist/core/mcp-ability-generator.js +193 -0
  8. package/dist/core/mcp-apps.d.ts +95 -0
  9. package/dist/core/mcp-apps.js +254 -0
  10. package/dist/core/mcp-directory.d.ts +32 -0
  11. package/dist/core/mcp-directory.js +191 -0
  12. package/dist/core/run-agent.js +5 -2
  13. package/dist/core/system-agents/interviewer.d.ts +1 -1
  14. package/dist/core/system-agents/interviewer.js +1 -1
  15. package/dist/core/system-agents/templates.d.ts +11 -0
  16. package/dist/core/system-agents/templates.js +22 -16
  17. package/dist/core/system-agents/workflow-planner.d.ts +36 -3
  18. package/dist/core/system-agents/workflow-planner.js +23 -6
  19. package/dist/core/workspace-abilities.js +1 -1
  20. package/dist/index.d.ts +7 -3
  21. package/dist/index.js +6 -2
  22. package/dist/web/abilities-admin.d.ts +1 -1
  23. package/dist/web/abilities-admin.js +1 -1
  24. package/dist/web/agents-config-page.js +187 -11
  25. package/dist/web/console-shell.js +7 -5
  26. package/dist/web/dev-ui-styles.js +1 -1
  27. package/dist/web/global-config-page.js +293 -13
  28. package/dist/web/interview-admin.js +2 -0
  29. package/dist/web/templates-admin.d.ts +10 -2
  30. package/dist/web/templates-admin.js +36 -3
  31. package/dist/web/templates-page.js +1 -0
  32. package/dist/web/workflow-admin.d.ts +46 -0
  33. package/dist/web/workflow-admin.js +195 -3
  34. package/dist/web/workflow-page.js +33 -3
  35. package/package.json +10 -1
@@ -11,6 +11,7 @@ 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 { planWorkflowWithModel, slugify, workflowTemplate } from '../core/system-agents/workflow-planner.js';
14
+ import { mcpAbilityNames } from '../core/mcp-ability-generator.js';
14
15
  import { readWorkflows, removeWorkflow, saveWorkflow } from '../core/workflows.js';
15
16
  import { createScheduledTask, listScheduledTasks, pauseScheduledTask, runScheduledTaskNow } from '../core/scheduler.js';
16
17
  import { workspaceTimeZone } from '../core/time-zone.js';
@@ -81,6 +82,155 @@ function specNames(spec) {
81
82
  const named = spec.match(/^([^@/:][^@]*)(@.*)?$/)?.[1];
82
83
  return [...new Set([scoped, named, last.replace(/@.*$/, '')].filter((n) => Boolean(n)))];
83
84
  }
85
+ /** Looks up each MCP server the plan's steps name: a server that can't
86
+ * be found leaves its steps missing; the rest are listed on the plan
87
+ * with the ability each would become and whether its key is set. */
88
+ export async function resolveMcpServers(plan, deps) {
89
+ const ids = [...new Set(plan.steps.flatMap((s) => (s.mcp ? [s.mcp] : [])))];
90
+ const servers = [];
91
+ for (const id of ids) {
92
+ const server = await deps.getServer(id).catch(() => undefined);
93
+ if (!server) {
94
+ for (const step of plan.steps) {
95
+ if (step.mcp !== id)
96
+ continue;
97
+ delete step.mcp;
98
+ step.missing = `the MCP server ${id} couldn't be found`;
99
+ }
100
+ plan.gaps = [...plan.gaps, `The MCP server ${id} couldn't be found, so its steps have no tool yet.`].slice(0, 10);
101
+ continue;
102
+ }
103
+ const names = mcpAbilityNames(server);
104
+ const existing = deps.existingFor(server.url);
105
+ servers.push({
106
+ id,
107
+ title: server.title,
108
+ url: server.url,
109
+ description: server.description,
110
+ verified: server.verified,
111
+ source: server.source,
112
+ ability: names.name,
113
+ keyEnv: names.keyEnv,
114
+ keySet: deps.keyValue(names.keyEnv) !== undefined,
115
+ ...(existing ? { existing: existing.spec } : {}),
116
+ });
117
+ }
118
+ if (servers.length)
119
+ plan.mcpServers = servers;
120
+ else
121
+ delete plan.mcpServers;
122
+ }
123
+ /** On Create: each MCP server on the plan becomes a private ability (or
124
+ * reuses the one already made from it), and its steps point at that
125
+ * ability's tools. A server that can't be set up leaves its steps
126
+ * missing and is reported. `keys` are what the person typed per server. */
127
+ export async function prepareMcpAbilities(plan, keys, deps) {
128
+ const made = [];
129
+ const failed = [];
130
+ for (const planned of plan.mcpServers ?? []) {
131
+ const steps = plan.steps.map((step, index) => ({ step, index })).filter(({ step }) => step.mcp === planned.id);
132
+ if (!steps.length)
133
+ continue;
134
+ const fail = (error) => {
135
+ failed.push({ name: planned.title, error });
136
+ for (const { step } of steps) {
137
+ delete step.mcp;
138
+ step.missing = `${planned.title}: ${error}`;
139
+ }
140
+ };
141
+ if (!deps) {
142
+ fail('setting up MCP servers isn\'t available on this server');
143
+ continue;
144
+ }
145
+ // The app, connected already or connected now; and the server tool for
146
+ // each step (the planner's when the app has it, else worked out).
147
+ const existing = deps.existingFor(planned.url);
148
+ const known = existing && steps.every(({ step }) => step.tool && existing.tools.some((t) => t.remote === step.tool));
149
+ let spec;
150
+ let stepTools;
151
+ let names;
152
+ if (existing && known) {
153
+ spec = existing.spec;
154
+ stepTools = Object.fromEntries(steps.map(({ step, index }) => [index, step.tool]));
155
+ names = existing.tools;
156
+ }
157
+ else {
158
+ const server = await deps.getServer(planned.id).catch(() => undefined);
159
+ if (!server) {
160
+ fail('the server couldn\'t be found any more');
161
+ continue;
162
+ }
163
+ const typed = keys[planned.id]?.trim();
164
+ const key = typed || deps.keyValue(planned.keyEnv);
165
+ let generated;
166
+ try {
167
+ generated = await deps.generate({
168
+ server,
169
+ ...(key ? { key } : {}),
170
+ workflow: { name: plan.name, summary: plan.summary, steps: steps.map(({ step, index }) => ({ index, title: step.title, kind: step.kind, ...(step.detail ? { detail: step.detail } : {}), ...(step.tool ? { tool: step.tool } : {}) })) },
171
+ });
172
+ }
173
+ catch (err) {
174
+ fail(err instanceof Error ? err.message : String(err));
175
+ continue;
176
+ }
177
+ stepTools = generated.stepTools;
178
+ names = generated.tools;
179
+ if (existing) {
180
+ // Connected already: only the step matching was needed.
181
+ spec = existing.spec;
182
+ }
183
+ else {
184
+ if (typed && generated.keyEnv) {
185
+ try {
186
+ await deps.saveKey(generated.keyEnv, typed);
187
+ }
188
+ catch (err) {
189
+ failed.push({ name: planned.title, error: `its key wasn't saved (${err instanceof Error ? err.message : String(err)}); add ${generated.keyEnv} from the checklist` });
190
+ if (!plan.keys.includes(generated.keyEnv))
191
+ plan.keys.push(generated.keyEnv);
192
+ }
193
+ }
194
+ spec = deps.store(generated.dir, `mcp:${server.url}`);
195
+ made.push(generated.name);
196
+ }
197
+ }
198
+ const installedName = (remote) => names.find((t) => t.remote === remote)?.name ?? remote;
199
+ const tools = [];
200
+ for (const { step, index } of steps) {
201
+ delete step.mcp;
202
+ step.ability = spec;
203
+ const remote = stepTools[index];
204
+ if (!remote) {
205
+ delete step.tool;
206
+ step.missing = `${planned.title} has no tool for this`;
207
+ continue;
208
+ }
209
+ step.tool = installedName(remote);
210
+ if (!tools.includes(remote))
211
+ tools.push(remote);
212
+ // An approval step's tool asks first, whatever its default.
213
+ if (step.kind === 'approval') {
214
+ const rule = plan.rules.find((r) => r.tool === step.tool);
215
+ if (rule)
216
+ rule.decision = 'ask';
217
+ else
218
+ plan.rules.push({ tool: step.tool, decision: 'ask' });
219
+ }
220
+ }
221
+ // The skill was written with the server's own tool names.
222
+ for (const { remote, name } of names) {
223
+ if (remote === name)
224
+ continue;
225
+ plan.skill.body = plan.skill.body.replace(new RegExp(`(?<![A-Za-z0-9_])${remote.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![A-Za-z0-9_])`, 'g'), name);
226
+ }
227
+ // Added to the agent as an app, with just these tools.
228
+ if (tools.length)
229
+ plan.apps = [...(plan.apps ?? []).filter((a) => a.id !== planned.id), { id: planned.id, title: planned.title, tools }];
230
+ }
231
+ delete plan.mcpServers;
232
+ return { made, failed };
233
+ }
84
234
  /** Plans a workflow from a description, then checks the suggested agent:
85
235
  * one that would have a decision changed, or doesn't exist, becomes a new
86
236
  * agent instead, saying why. */
@@ -96,6 +246,14 @@ export async function planWorkflow(description, modelCall, tools) {
96
246
  const plan = result.plan;
97
247
  const agents = await tools.agentInfo();
98
248
  resolveConnections(plan, agents);
249
+ if (tools.mcp)
250
+ await resolveMcpServers(plan, tools.mcp);
251
+ else
252
+ for (const step of plan.steps)
253
+ if (step.mcp) {
254
+ delete step.mcp;
255
+ step.missing = step.missing ?? 'no ability does this';
256
+ }
99
257
  if (plan.target.kind !== 'existing' || !plan.target.agent)
100
258
  return { plan };
101
259
  const agent = agents.find((a) => a.name === plan.target.agent);
@@ -160,6 +318,8 @@ export async function createWorkflow(input, deps) {
160
318
  });
161
319
  const agents = await deps.agentInfo();
162
320
  resolveConnections(plan, agents);
321
+ 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()]] : []))) : {};
322
+ const mcp = await prepareMcpAbilities(plan, keys, deps.mcp);
163
323
  const template = workflowTemplate(plan);
164
324
  if (target.kind !== 'existing') {
165
325
  const created = await createFromTemplateObject(template, deps);
@@ -171,7 +331,7 @@ export async function createWorkflow(input, deps) {
171
331
  agent: created.name,
172
332
  newAgent: true,
173
333
  workflow: plan.name,
174
- abilities: { installed: [...created.abilities.installed, ...connected.copied], failed: [...created.abilities.failed, ...connected.failed] },
334
+ abilities: { installed: [...created.abilities.installed, ...connected.copied], failed: [...mcp.failed, ...created.abilities.failed, ...connected.failed] },
175
335
  keptDecisions: [],
176
336
  checklist: withMissingSteps(plan, created.name, created.checklist),
177
337
  ...(created.schedule ? { schedule: created.schedule } : {}),
@@ -196,7 +356,7 @@ export async function createWorkflow(input, deps) {
196
356
  }
197
357
  // 2. Abilities it doesn't have yet.
198
358
  const installed = [];
199
- const failed = [];
359
+ const failed = [...mcp.failed];
200
360
  for (const spec of check.toInstall) {
201
361
  if (!deps.installAbility) {
202
362
  failed.push({ name: spec, error: 'installing abilities needs LOOPENGINE_ADMIN_AUTH set on the server' });
@@ -213,6 +373,38 @@ export async function createWorkflow(input, deps) {
213
373
  failed.push({ name: spec, error: installFailureReason(err) });
214
374
  }
215
375
  }
376
+ // Apps: added with just the tools the steps use, or those tools added
377
+ // to the agent's when it has the app already.
378
+ const appsToConnect = [];
379
+ for (const app of plan.apps ?? []) {
380
+ const spec = deps.prepareApp ? await deps.prepareApp(app.id) : undefined;
381
+ if (!spec) {
382
+ appsToConnect.push(app.title);
383
+ continue;
384
+ }
385
+ if (!deps.installAbility) {
386
+ failed.push({ name: app.title, error: 'adding apps needs LOOPENGINE_ADMIN_AUTH set on the server' });
387
+ continue;
388
+ }
389
+ try {
390
+ await deps.installAbility(agent.name, spec, { mcpTools: app.tools });
391
+ installed.push(app.title);
392
+ }
393
+ catch (err) {
394
+ const message = err instanceof Error ? err.message : String(err);
395
+ if (/already installed/i.test(message) && deps.addAppTools) {
396
+ try {
397
+ await deps.addAppTools(agent.name, spec, app.tools);
398
+ installed.push(app.title);
399
+ }
400
+ catch (e) {
401
+ failed.push({ name: app.title, error: e instanceof Error ? e.message : String(e) });
402
+ }
403
+ }
404
+ else
405
+ failed.push({ name: app.title, error: installFailureReason(err) });
406
+ }
407
+ }
216
408
  // Connected-service tools it doesn't have yet.
217
409
  const connected = copyConnectedTools(plan, agent.name, agents, deps.agentDir);
218
410
  installed.push(...connected.copied);
@@ -252,7 +444,7 @@ export async function createWorkflow(input, deps) {
252
444
  workflow: plan.name,
253
445
  abilities: { installed, failed },
254
446
  keptDecisions: check.conflicts,
255
- checklist: withMissingSteps(plan, agent.name, templateChecklist(template, agent.name, deps, failed)),
447
+ checklist: withMissingSteps(plan, agent.name, templateChecklist(template, agent.name, deps, failed, appsToConnect)),
256
448
  ...(schedule ? { schedule } : {}),
257
449
  ...(scheduleError ? { scheduleError } : {}),
258
450
  ...(loadError ? { loadError } : {}),
@@ -36,6 +36,9 @@ export const workflowPageHtml = `<!doctype html>
36
36
  .wf-btn { display: inline-block; padding: 7px 14px; border-radius: 6px; text-decoration: none; font-size: 13px; font-weight: 600; background: var(--accent); color: var(--on-accent); border: 0; cursor: pointer; }
37
37
  .wf-btn.plain { background: none; color: var(--ink); border: 1px solid var(--line); font-weight: 500; }
38
38
  .wf-btn:disabled { opacity: 0.6; cursor: progress; }
39
+ .wf-mcp { display: grid; gap: 10px; }
40
+ .wf-mcp-row { border: 1px solid var(--line); border-radius: var(--radius); padding: 10px 12px; display: grid; gap: 6px; background: var(--surface); }
41
+ .wf-mcp-row input { font: inherit; padding: 6px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--bg); color: var(--ink); width: 100%; max-width: 360px; box-sizing: border-box; }
39
42
  .hint { font-size: 12px; color: var(--ink-muted); }
40
43
  .error { font-size: 13px; color: var(--bad); }
41
44
  </style>
@@ -134,8 +137,16 @@ ${consoleShellScript}
134
137
  }
135
138
 
136
139
  // ---- 2. Check the plan ----
140
+ function mcpTitle(id) {
141
+ var servers = (state.planned && state.planned.plan.mcpServers) || [];
142
+ for (var i = 0; i < servers.length; i++) if (servers[i].id === id) return servers[i].title;
143
+ return id;
144
+ }
145
+
137
146
  function stepRow(step, i) {
138
- var becomes = step.tool
147
+ var becomes = step.mcp
148
+ ? (step.tool ? '<code>' + esc(step.tool) + '</code> ' : '') + '<span class="hint">from ' + esc(mcpTitle(step.mcp)) + ' (MCP server)' + (step.tool ? '' : ', tool picked on Create') + '</span>'
149
+ : step.tool
139
150
  ? '<code>' + esc(step.tool) + '</code>' + (step.ability ? ' <span class="hint">from ' + esc(step.ability) + '</span>' : step.connection ? ' <span class="hint">connected service: ' + esc(step.connection) + '</span>' : (step.tool === 'system_ask_user' ? ' <span class="hint">built in</span>' : ''))
140
151
  : '<span class="hint">the agent' + String.fromCharCode(8217) + 's own work</span>';
141
152
  var badge = step.missing
@@ -148,6 +159,21 @@ ${consoleShellScript}
148
159
  return '<tr><td class="wf-num">' + (i + 1) + '</td><td><strong>' + esc(step.title) + '</strong>' + (step.detail ? '<div class="hint">' + esc(step.detail) + '</div>' : '') + '</td><td>' + becomes + '</td><td>' + badge + '</td></tr>';
149
160
  }
150
161
 
162
+ function mcpRow(s) {
163
+ var badge = s.verified ? '<span class="le-pill allow">' + (s.source === 'saved' ? 'Saved server' : 'Verified') + '</span>' : '<span class="le-pill ask">Unverified</span>';
164
+ var what = s.existing
165
+ ? 'Uses <code>' + esc(s.ability) + '</code>, already in your workspace.'
166
+ : 'Connected to the workspace when you create this, as <code>' + esc(s.ability) + '</code>; the agent gets just the tools its steps use (anything that changes data asks you first).';
167
+ var key = s.existing ? '' :
168
+ '<label class="hint" for="wfKey-' + esc(s.ability) + '">API key' + (s.keySet ? ' (one is saved; leave blank to use it)' : ' (if it needs one)') + '</label>' +
169
+ '<input type="password" autocomplete="off" id="wfKey-' + esc(s.ability) + '" data-mcp-key="' + esc(s.id) + '" placeholder="' + esc(s.keyEnv) + '">';
170
+ return '<div class="wf-mcp-row"><div><strong>' + esc(s.title) + '</strong> ' + badge + '</div>' +
171
+ '<div class="hint">' + esc(s.url) + (s.description ? ' ' + DASH + ' ' + esc(s.description) : '') + '</div>' +
172
+ '<div class="hint">' + what + '</div>' + key +
173
+ (s.verified ? '' : '<div class="hint">Not published on the app' + String.fromCharCode(8217) + 's own domain: only add a key if you trust who runs it.</div>') +
174
+ '</div>';
175
+ }
176
+
151
177
  function renderPlan(error) {
152
178
  var p = state.planned.plan;
153
179
  var suggested = p.target.kind === 'existing' && p.target.agent ? p.target.agent : '';
@@ -160,6 +186,7 @@ ${consoleShellScript}
160
186
  '<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>' +
161
187
  (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>' : '') +
162
188
  (p.abilities.length ? '<h3>Abilities it uses</h3><ul class="wf-list">' + p.abilities.map(function (a) { return '<li><code>' + esc(a) + '</code></li>'; }).join('') + '</ul>' + (state.canInstall ? '' : '<p class="hint">Installing abilities needs LOOPENGINE_ADMIN_AUTH set; otherwise they head the checklist.</p>') : '') +
189
+ (p.mcpServers && p.mcpServers.length ? '<h3>Apps it connects to</h3><div class="wf-mcp">' + p.mcpServers.map(mcpRow).join('') + '</div>' : '') +
163
190
  (p.keys.length ? '<h3>Keys it needs</h3><p class="hint" style="margin:0">' + p.keys.map(esc).join(', ') + ' ' + DASH + ' you add them after, from the checklist.</p>' : '') +
164
191
  (p.needsApprovals ? '<p class="hint" style="margin:0">It asks you through the agent' + String.fromCharCode(8217) + 's primary messaging app (Messaging tab) when nobody is in Chat.</p>' : '') +
165
192
  '</div>' +
@@ -184,8 +211,11 @@ ${consoleShellScript}
184
211
  var target = chosen.value === 'new' ? { kind: 'new' } : { kind: 'existing', agent: chosen.value === 'other' ? document.getElementById('wfOther').value : chosen.dataset.agent };
185
212
  var btn = document.getElementById('wfCreate');
186
213
  btn.disabled = true;
187
- document.getElementById('wfStatus').textContent = 'Creating' + ELLIPSIS + ' installing abilities can take a minute.';
188
- post('/workflows/create', { plan: p, target: target, description: state.description }).then(function (r) {
214
+ var mcpKeys = {};
215
+ var keyInputs = page.querySelectorAll('[data-mcp-key]');
216
+ for (var k = 0; k < keyInputs.length; k++) if (keyInputs[k].value.trim()) mcpKeys[keyInputs[k].dataset.mcpKey] = keyInputs[k].value.trim();
217
+ document.getElementById('wfStatus').textContent = 'Creating' + ELLIPSIS + (p.mcpServers && p.mcpServers.length ? ' connecting to its apps and installing abilities can take a minute or two.' : ' installing abilities can take a minute.');
218
+ post('/workflows/create', { plan: p, target: target, description: state.description, mcpKeys: mcpKeys }).then(function (r) {
189
219
  if (!r.ok) { renderPlan(r.body.error || 'Could not create it.'); return; }
190
220
  renderDone(r.body);
191
221
  }).catch(function (err) { renderPlan(err.message); });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopengine",
3
- "version": "0.1.98",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "Runtime for tool-using AI agents with a transparent ReAct loop.",
6
6
  "license": "MIT",
@@ -62,6 +62,14 @@
62
62
  "dist/core/ability-sources.d.ts",
63
63
  "dist/core/ability-details.js",
64
64
  "dist/core/ability-details.d.ts",
65
+ "dist/core/mcp-abilities.js",
66
+ "dist/core/mcp-abilities.d.ts",
67
+ "dist/core/mcp-directory.js",
68
+ "dist/core/mcp-directory.d.ts",
69
+ "dist/core/mcp-ability-generator.js",
70
+ "dist/core/mcp-ability-generator.d.ts",
71
+ "dist/core/mcp-apps.js",
72
+ "dist/core/mcp-apps.d.ts",
65
73
  "dist/core/workspace-abilities.js",
66
74
  "dist/core/workspace-abilities.d.ts",
67
75
  "dist/core/time-zone.d.ts",
@@ -129,6 +137,7 @@
129
137
  },
130
138
  "dependencies": {
131
139
  "@anthropic-ai/sdk": "^0.116.0",
140
+ "@modelcontextprotocol/sdk": "1.32.1",
132
141
  "actauth": "^0.0.12",
133
142
  "ioredis": "^6.0.0",
134
143
  "openai": "^7.4.0",