loopengine 0.1.59 → 0.1.61

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.
@@ -26,6 +26,17 @@ export interface AbilityEnvDecl {
26
26
  * env-admin.ts's own serializeEnvValue doc comment). Omit for anything
27
27
  * genuinely single-line. */
28
28
  multiline?: boolean;
29
+ /** True when this setting is meaningfully different per agent (e.g.
30
+ * which Slack channel to post to) rather than one real shared
31
+ * credential every installed copy should agree on (e.g. an API key
32
+ * for the one external service they all call). The Admin UI's
33
+ * Environment tab then also offers a per-agent override — a second,
34
+ * separate var named `<AGENT_NAME>_<this name>` (env-admin.ts's own
35
+ * agentScopedEnvVarName) — that an ability's own tool code can check
36
+ * first, falling back to this bare name when the override isn't set.
37
+ * Omit (the default, false) for anything that's actually fine shared
38
+ * across every agent that installs this ability. */
39
+ perAgent?: boolean;
29
40
  }
30
41
  /** `name`/`version` deliberately aren't declared here — they're read off
31
42
  * the ability's own sibling `package.json` instead (see readManifest),
@@ -35,6 +35,25 @@ export type GatewayToolEntry = ComposioGatewayToolEntry;
35
35
  * module's own registry read/writes do, rather than re-deriving it (e.g.
36
36
  * from process.cwd()) and risking the two drifting apart. */
37
37
  export declare function agentDir(agentName: string): string;
38
+ /** agentDir's own inverse, for a tool file that needs to know which
39
+ * agent it was installed under without an env var to tell it — an
40
+ * ability's own tool file (copied to agents/<name>/tools/<file>.ts by
41
+ * add-ability) calls this with its own `import.meta.url`, and gets
42
+ * `<name>` back, by walking back up from the file's real on-disk
43
+ * location rather than trusting anything passed in (there's nothing
44
+ * else to trust: this runs from inside the tool file itself, before
45
+ * any agent-scoped state exists). Returns undefined for a tool file
46
+ * that isn't actually at that expected agents/<name>/tools/ depth (a
47
+ * test harness loading the file from somewhere else, say) — a caller
48
+ * needing a default when this can't be determined should fall back
49
+ * explicitly, not treat undefined as a crash. Mirrors the inferAgentName
50
+ * helper lp-task-scheduler's own tool files (schedule_task.ts et al.)
51
+ * already hand-duplicate per the ability system's own "no shared
52
+ * modules between tool files" rule — this is the one place that's
53
+ * actually safe to centralize, since every ability's tool file already
54
+ * imports real functions from this package (see e.g. lp-task-scheduler's
55
+ * own createCheckpointStore/runAgent imports), just not yet this. */
56
+ export declare function inferAgentNameFromToolUrl(toolFileUrl: string): string | undefined;
38
57
  /** `dir` is the agent's real folder — `agents/<name>` for a top-level
39
58
  * agent, or the nested `agents/<parent>/subagents/<child>` a subagent
40
59
  * actually lives in (see run-agent.ts's resolveSubagentConfig, which
@@ -8,7 +8,8 @@
8
8
  // the only intended writer of that file; loadGatewayToolsFromDir is what
9
9
  // run-agent.ts calls to actually resolve it into ToolDefinitions.
10
10
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
11
- import { join } from 'node:path';
11
+ import { basename, dirname, join } from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
12
13
  import { execFile } from 'node:child_process';
13
14
  import { promisify } from 'node:util';
14
15
  import { parse as parseYaml, parseDocument, stringify as stringifyYaml } from 'yaml';
@@ -34,6 +35,36 @@ function gatewayToolsPath(dir) {
34
35
  export function agentDir(agentName) {
35
36
  return join(agentsRootDir, agentName);
36
37
  }
38
+ /** agentDir's own inverse, for a tool file that needs to know which
39
+ * agent it was installed under without an env var to tell it — an
40
+ * ability's own tool file (copied to agents/<name>/tools/<file>.ts by
41
+ * add-ability) calls this with its own `import.meta.url`, and gets
42
+ * `<name>` back, by walking back up from the file's real on-disk
43
+ * location rather than trusting anything passed in (there's nothing
44
+ * else to trust: this runs from inside the tool file itself, before
45
+ * any agent-scoped state exists). Returns undefined for a tool file
46
+ * that isn't actually at that expected agents/<name>/tools/ depth (a
47
+ * test harness loading the file from somewhere else, say) — a caller
48
+ * needing a default when this can't be determined should fall back
49
+ * explicitly, not treat undefined as a crash. Mirrors the inferAgentName
50
+ * helper lp-task-scheduler's own tool files (schedule_task.ts et al.)
51
+ * already hand-duplicate per the ability system's own "no shared
52
+ * modules between tool files" rule — this is the one place that's
53
+ * actually safe to centralize, since every ability's tool file already
54
+ * imports real functions from this package (see e.g. lp-task-scheduler's
55
+ * own createCheckpointStore/runAgent imports), just not yet this. */
56
+ export function inferAgentNameFromToolUrl(toolFileUrl) {
57
+ try {
58
+ const toolsDir = dirname(fileURLToPath(toolFileUrl));
59
+ const agentDir = dirname(toolsDir);
60
+ if (basename(dirname(agentDir)) !== 'agents')
61
+ return undefined;
62
+ return basename(agentDir);
63
+ }
64
+ catch {
65
+ return undefined;
66
+ }
67
+ }
37
68
  /** `dir` is the agent's real folder — `agents/<name>` for a top-level
38
69
  * agent, or the nested `agents/<parent>/subagents/<child>` a subagent
39
70
  * actually lives in (see run-agent.ts's resolveSubagentConfig, which
@@ -0,0 +1,25 @@
1
+ ---
2
+ name: composio-large-outputs
3
+ description: How to retrieve a gateway tool's real output when its result says storedInFile is true instead of returning the data inline.
4
+ ---
5
+
6
+ # Gateway tools with a large result
7
+
8
+ Some gateway tools (Composio-sourced ones in particular) return a small
9
+ pointer object instead of their real output when the actual result is
10
+ too large to return inline:
11
+
12
+ ```
13
+ {
14
+ "successful": true,
15
+ "storedInFile": true,
16
+ "tokenCount": 10234,
17
+ "outputFilePath": "/tmp/.../SOME_TOOL_OUTPUT_xxxxx.json"
18
+ }
19
+ ```
20
+
21
+ When you see `storedInFile: true` in a tool result, the real data is
22
+ **not** in that object — it's in the file at `outputFilePath`. Call
23
+ `system_read_file` with that exact path to retrieve the actual content
24
+ before answering. Don't guess at an answer, summarize from the pointer
25
+ alone, or claim you don't have the data — read the file first.
package/dist/index.d.ts CHANGED
@@ -36,7 +36,7 @@ export { LLMSummarizer } from './core/llm-summarizer.js';
36
36
  export type { LLMSummarizerOptions } from './core/llm-summarizer.js';
37
37
  export { createCheckpointStore } from './core/durable-approvals.js';
38
38
  export type { TurnCheckpoint, CheckpointStore, OutstandingItem } from './core/durable-approvals.js';
39
- export { addGatewayTool, agentDir, disconnectComposioAccount, describeGatewayTools, listComposioConnections, listComposioTools, loadGatewayToolsFromDir, removeGatewayTool, removeGatewayToolSlug, GatewayToolExistsError, GatewayToolNotFoundError, } from './core/gateway-tools.js';
39
+ export { addGatewayTool, agentDir, inferAgentNameFromToolUrl, disconnectComposioAccount, describeGatewayTools, listComposioConnections, listComposioTools, loadGatewayToolsFromDir, removeGatewayTool, removeGatewayToolSlug, GatewayToolExistsError, GatewayToolNotFoundError, } from './core/gateway-tools.js';
40
40
  export type { GatewayToolEntry, GatewayToolDecision } from './core/gateway-tools.js';
41
41
  export { connectComposioSource } from './core/mcpplug.js';
42
42
  export type { ToolSource, ComposioSourceOptions } from './core/mcpplug.js';
@@ -55,11 +55,11 @@ export { globalConfigPageHtml } from './web/global-config-page.js';
55
55
  export { playgroundHtml } from './web/playground.js';
56
56
  export { readSkill, writeSkill, deleteSkill, SkillInvalidIdError, SkillNotFoundError } from './web/skills-admin.js';
57
57
  export type { SkillContent } from './web/skills-admin.js';
58
- export { listDeclaredEnvVars, setEnvVar, unsetEnvVar, EnvVarNameError } from './web/env-admin.js';
58
+ export { listDeclaredEnvVars, setEnvVar, unsetEnvVar, agentScopedEnvVarName, EnvVarNameError } from './web/env-admin.js';
59
59
  export type { DeclaredEnvVar } from './web/env-admin.js';
60
60
  export { readActauthConfig, addActauthRule, updateActauthRule, removeActauthRule, setDefaultDecision, ActauthRuleExistsError, ActauthRuleNotFoundError, } from './web/actauth-admin.js';
61
61
  export type { ActauthRuleInput, ActauthConfigView } from './web/actauth-admin.js';
62
- export { installAbility, upgradeAbility, listInstalledAbilities, backfillDependencies, AbilityManifestError, AbilityVersionError, AbilityCollisionError, AbilityAlreadyInstalledError, AbilityNotInstalledError, } from './bin/ability-manager.js';
62
+ export { installAbility, upgradeAbility, removeAbility, listInstalledAbilities, backfillDependencies, AbilityManifestError, AbilityVersionError, AbilityCollisionError, AbilityAlreadyInstalledError, AbilityNotInstalledError, } from './bin/ability-manager.js';
63
63
  export type { InstalledAbilityRecord, AbilityEnvDecl, UpgradeFileResult } from './bin/ability-manager.js';
64
64
  export { searchPublicAbilities, fetchLatestAbilityVersion } from './web/abilities-admin.js';
65
65
  export type { PublicAbilitySearchResult } from './web/abilities-admin.js';
package/dist/index.js CHANGED
@@ -35,7 +35,7 @@ export { resumeAgent, loadRules, loadDefaultTools, loadSubagentAsTools, systemTo
35
35
  export { Compactor, TruncatingSummarizer } from './core/compaction.js';
36
36
  export { LLMSummarizer } from './core/llm-summarizer.js';
37
37
  export { createCheckpointStore } from './core/durable-approvals.js';
38
- export { addGatewayTool, agentDir, disconnectComposioAccount, describeGatewayTools, listComposioConnections, listComposioTools, loadGatewayToolsFromDir, removeGatewayTool, removeGatewayToolSlug, GatewayToolExistsError, GatewayToolNotFoundError, } from './core/gateway-tools.js';
38
+ export { addGatewayTool, agentDir, inferAgentNameFromToolUrl, disconnectComposioAccount, describeGatewayTools, listComposioConnections, listComposioTools, loadGatewayToolsFromDir, removeGatewayTool, removeGatewayToolSlug, GatewayToolExistsError, GatewayToolNotFoundError, } from './core/gateway-tools.js';
39
39
  export { connectComposioSource } from './core/mcpplug.js';
40
40
  export { ToolLane, buildLanes, runLanes } from './core/toollane.js';
41
41
  export { WebhookNotifier } from './core/http-notify-triggers/webhook.js';
@@ -50,9 +50,9 @@ export { agentsListPageHtml } from './web/agents-list-page.js';
50
50
  export { globalConfigPageHtml } from './web/global-config-page.js';
51
51
  export { playgroundHtml } from './web/playground.js';
52
52
  export { readSkill, writeSkill, deleteSkill, SkillInvalidIdError, SkillNotFoundError } from './web/skills-admin.js';
53
- export { listDeclaredEnvVars, setEnvVar, unsetEnvVar, EnvVarNameError } from './web/env-admin.js';
53
+ export { listDeclaredEnvVars, setEnvVar, unsetEnvVar, agentScopedEnvVarName, EnvVarNameError } from './web/env-admin.js';
54
54
  export { readActauthConfig, addActauthRule, updateActauthRule, removeActauthRule, setDefaultDecision, ActauthRuleExistsError, ActauthRuleNotFoundError, } from './web/actauth-admin.js';
55
- export { installAbility, upgradeAbility, listInstalledAbilities, backfillDependencies, AbilityManifestError, AbilityVersionError, AbilityCollisionError, AbilityAlreadyInstalledError, AbilityNotInstalledError, } from './bin/ability-manager.js';
55
+ export { installAbility, upgradeAbility, removeAbility, listInstalledAbilities, backfillDependencies, AbilityManifestError, AbilityVersionError, AbilityCollisionError, AbilityAlreadyInstalledError, AbilityNotInstalledError, } from './bin/ability-manager.js';
56
56
  export { searchPublicAbilities, fetchLatestAbilityVersion } from './web/abilities-admin.js';
57
57
  export { describeModelProviders, describeGateways } from './web/global-config.js';
58
58
  export { createTrackedApprover, listApprovals, decideApproval, findApproval } from './web/web-approver.js';
@@ -1379,6 +1379,7 @@ export const agentsConfigPageHtml = `<!doctype html>
1379
1379
  if (deps.length) {
1380
1380
  actionHtml += ' <button type="button" class="ability-install-deps-btn" data-name="' + escapeHtml(a.name) + '" title="npm install ' + escapeHtml(deps.join(' ')) + '">Install deps</button>';
1381
1381
  }
1382
+ actionHtml += ' <button type="button" class="ability-uninstall-btn" data-name="' + escapeHtml(a.name) + '">Uninstall</button>';
1382
1383
  return '<tr>' +
1383
1384
  '<td><code>' + escapeHtml(a.name) + '</code></td>' +
1384
1385
  '<td>' + escapeHtml(a.version) + '</td>' +
@@ -1544,6 +1545,49 @@ export const agentsConfigPageHtml = `<!doctype html>
1544
1545
  });
1545
1546
  }
1546
1547
 
1548
+ // Deletes an ability's tool/skill files and actauth rules
1549
+ // (handleAbilityUninstallDelete / bin/ability-manager.ts's own
1550
+ // removeAbility). A file hand-modified since install is left in place
1551
+ // and reported back as refused rather than silently deleted — the
1552
+ // confirm() below offers to retry with force once the operator has
1553
+ // seen exactly which files those are, same two-step the CLI itself
1554
+ // takes with --force. Always reloads the tab afterward: a partial
1555
+ // uninstall (some files refused) still changes what is installed,
1556
+ // it just does not remove the ability entirely from provenance.
1557
+ function uninstallAbility(name, abilityName, statusEl, btn, force) {
1558
+ if (btn) { btn.disabled = true; }
1559
+ statusEl.className = 'hint';
1560
+ statusEl.textContent = 'Uninstalling ' + abilityName + '...';
1561
+ var url = '/agents/' + encodeURIComponent(name) + '/abilities/' + encodeURIComponent(abilityName) + (force ? '?force=true' : '');
1562
+ fetch(url, { method: 'DELETE' })
1563
+ .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
1564
+ .then(function (result) {
1565
+ if (!result.ok) throw new Error(result.body.error || 'request failed');
1566
+ var removed = result.body.removed || [];
1567
+ var refused = result.body.refused || [];
1568
+ if (refused.length && !force) {
1569
+ var retry = confirm(
1570
+ abilityName + ': ' + refused.length + ' file(s) were hand-modified since install and left in place (' + refused.join(', ') + '). Remove them anyway?',
1571
+ );
1572
+ if (retry) {
1573
+ uninstallAbility(name, abilityName, statusEl, btn, true);
1574
+ return;
1575
+ }
1576
+ }
1577
+ statusEl.className = refused.length ? 'error' : 'hint';
1578
+ statusEl.textContent = removed.length
1579
+ ? 'Removed ' + removed.join(', ') + '.' + (refused.length ? ' Left in place (hand-modified): ' + refused.join(', ') + '.' : '')
1580
+ : 'Nothing removed.';
1581
+ if (btn) { btn.disabled = false; }
1582
+ loadAbilitiesTab(name);
1583
+ })
1584
+ .catch(function (err) {
1585
+ statusEl.className = 'error';
1586
+ statusEl.textContent = 'Could not uninstall: ' + err.message;
1587
+ if (btn) { btn.disabled = false; }
1588
+ });
1589
+ }
1590
+
1547
1591
  // Shared by the initial (empty-query) load right after the tab opens
1548
1592
  // and the search form's own submit — the empty-query case is just
1549
1593
  // "list every published ability" (the keyword filter alone, no extra
@@ -1593,6 +1637,16 @@ export const agentsConfigPageHtml = `<!doctype html>
1593
1637
  });
1594
1638
  }
1595
1639
 
1640
+ var uninstallBtns = content.querySelectorAll('.ability-uninstall-btn');
1641
+ for (var r = 0; r < uninstallBtns.length; r++) {
1642
+ uninstallBtns[r].addEventListener('click', function (ev) {
1643
+ var btn = ev.currentTarget;
1644
+ var abilityName = btn.dataset.name;
1645
+ if (!confirm('Uninstall ' + abilityName + '? This deletes its tool and skill files and actauth rules from this agent.')) return;
1646
+ uninstallAbility(name, abilityName, upgradeStatusEl, btn, false);
1647
+ });
1648
+ }
1649
+
1596
1650
  var searchForm = content.querySelector('#abilitySearchForm');
1597
1651
  var resultsEl = content.querySelector('#abilitySearchResults');
1598
1652
  if (searchForm && resultsEl) {
@@ -48,6 +48,16 @@ export interface DeclaredEnvVar {
48
48
  * live off `process.env`, not cached, so it reflects whatever the last
49
49
  * `setEnvVar` call — or a plain restart picking up `.env` — actually
50
50
  * did. */
51
+ /** `varName`'s own per-agent override name (see AbilityEnvDecl.perAgent)
52
+ * — `<AGENT_NAME>_<varName>`, with `agentName` upper-cased and anything
53
+ * that isn't `[A-Z0-9_]` (a hyphen, most likely — agent directory names
54
+ * are kebab-case) turned into `_`, same alphabet ENV_VAR_NAME_PATTERN
55
+ * already requires. Guards against a result starting with a digit (a
56
+ * from-digits agent name, or an agent name that's entirely punctuation)
57
+ * the same way that pattern would otherwise reject — by prefixing one
58
+ * more `_` — though in practice agent names are never actually bare
59
+ * digits. */
60
+ export declare function agentScopedEnvVarName(agentName: string, varName: string): string;
51
61
  export declare function listDeclaredEnvVars(agentName: string): DeclaredEnvVar[];
52
62
  /** Upserts `NAME=VALUE` into the project's `.env` file — preserving
53
63
  * every other line (comments, blank lines, unrelated keys) — and
@@ -41,33 +41,61 @@ function provenancePath(agentName) {
41
41
  * live off `process.env`, not cached, so it reflects whatever the last
42
42
  * `setEnvVar` call — or a plain restart picking up `.env` — actually
43
43
  * did. */
44
+ /** `varName`'s own per-agent override name (see AbilityEnvDecl.perAgent)
45
+ * — `<AGENT_NAME>_<varName>`, with `agentName` upper-cased and anything
46
+ * that isn't `[A-Z0-9_]` (a hyphen, most likely — agent directory names
47
+ * are kebab-case) turned into `_`, same alphabet ENV_VAR_NAME_PATTERN
48
+ * already requires. Guards against a result starting with a digit (a
49
+ * from-digits agent name, or an agent name that's entirely punctuation)
50
+ * the same way that pattern would otherwise reject — by prefixing one
51
+ * more `_` — though in practice agent names are never actually bare
52
+ * digits. */
53
+ export function agentScopedEnvVarName(agentName, varName) {
54
+ const prefix = agentName.toUpperCase().replace(/[^A-Z0-9_]/g, '_');
55
+ return `${/^[0-9]/.test(prefix) ? '_' : ''}${prefix}_${varName}`;
56
+ }
44
57
  export function listDeclaredEnvVars(agentName) {
45
58
  const path = provenancePath(agentName);
46
59
  if (!existsSync(path))
47
60
  return [];
48
61
  const provenance = JSON.parse(readFileSync(path, 'utf8'));
49
62
  const byName = new Map();
63
+ function upsert(name, abilityName, decl, description) {
64
+ const existing = byName.get(name);
65
+ if (existing) {
66
+ existing.abilityNames.push(abilityName);
67
+ existing.secret = existing.secret || decl.secret === true;
68
+ if (existing.secret)
69
+ existing.value = undefined;
70
+ return;
71
+ }
72
+ const rawValue = process.env[name];
73
+ byName.set(name, {
74
+ name,
75
+ description,
76
+ secret: decl.secret === true,
77
+ abilityNames: [abilityName],
78
+ set: rawValue !== undefined,
79
+ value: decl.secret === true ? undefined : rawValue,
80
+ options: decl.options,
81
+ multiline: decl.multiline,
82
+ });
83
+ }
50
84
  for (const [abilityName, record] of Object.entries(provenance)) {
51
85
  for (const decl of record.env) {
52
- const existing = byName.get(decl.name);
53
- if (existing) {
54
- existing.abilityNames.push(abilityName);
55
- existing.secret = existing.secret || decl.secret === true;
56
- if (existing.secret)
57
- existing.value = undefined;
58
- continue;
86
+ upsert(decl.name, abilityName, decl, decl.description);
87
+ // A perAgent var also gets its own separate, independently
88
+ // set/unset row for this one agent's override — see
89
+ // AbilityEnvDecl.perAgent's own doc comment for why this is a
90
+ // second row, not a replacement for the shared one above (an
91
+ // ability's own tool code checks the override first and falls
92
+ // back to the shared value, so both need to stay independently
93
+ // visible and settable here).
94
+ if (decl.perAgent) {
95
+ const scopedName = agentScopedEnvVarName(agentName, decl.name);
96
+ const overrideDescription = `Overrides ${decl.name} for this agent only — if unset, falls back to the shared value above.${decl.description ? ` ${decl.description}` : ''}`;
97
+ upsert(scopedName, abilityName, decl, overrideDescription);
59
98
  }
60
- const rawValue = process.env[decl.name];
61
- byName.set(decl.name, {
62
- name: decl.name,
63
- description: decl.description,
64
- secret: decl.secret === true,
65
- abilityNames: [abilityName],
66
- set: rawValue !== undefined,
67
- value: decl.secret === true ? undefined : rawValue,
68
- options: decl.options,
69
- multiline: decl.multiline,
70
- });
71
99
  }
72
100
  }
73
101
  return [...byName.values()];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopengine",
3
- "version": "0.1.59",
3
+ "version": "0.1.61",
4
4
  "type": "module",
5
5
  "description": "Runtime for tool-using AI agents with a transparent ReAct loop.",
6
6
  "license": "MIT",