loopengine 0.1.24 → 0.1.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/ability-manager.js +155 -27
- package/dist/web/agents-config-page.js +11 -2
- package/dist/web/env-admin.d.ts +27 -8
- package/dist/web/env-admin.js +32 -14
- package/dist/web/http-tool-admin.d.ts +23 -12
- package/dist/web/http-tool-admin.js +58 -28
- package/package.json +1 -1
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
// "Upgrading" section).
|
|
19
19
|
import { createHash } from 'node:crypto';
|
|
20
20
|
import { execFileSync } from 'node:child_process';
|
|
21
|
-
import { basename, join } from 'node:path';
|
|
22
|
-
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
21
|
+
import { basename, dirname, join } from 'node:path';
|
|
22
|
+
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
23
23
|
import { tmpdir } from 'node:os';
|
|
24
24
|
import { parse as parseYaml } from 'yaml';
|
|
25
25
|
import semver from 'semver';
|
|
@@ -47,6 +47,76 @@ export class AbilityAlreadyInstalledError extends Error {
|
|
|
47
47
|
// the agent's own root instead of its skills/ folder.
|
|
48
48
|
const TOOL_NAME_PATTERN = /^[a-z][a-z0-9_]*$/;
|
|
49
49
|
const SKILL_ID_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
50
|
+
/** Two abilities can each declare a skill with the same bare id (both
|
|
51
|
+
* ship a "web-search" skill, say) — refusing the second install outright
|
|
52
|
+
* would make them mutually exclusive for no real reason, since skills
|
|
53
|
+
* don't have to be globally unique the way a model-callable tool name
|
|
54
|
+
* does. Namespacing under the ability's own name only when the bare id
|
|
55
|
+
* is already taken — never unconditionally — keeps the common,
|
|
56
|
+
* no-conflict case's directory layout exactly as it's always been.
|
|
57
|
+
* Reuses SkillGarden's own nested-directory namespacing (discovery.ts:
|
|
58
|
+
* `deploy/web/SKILL.md` -> addressable as `deploy:web`) for free, so a
|
|
59
|
+
* namespaced skill just becomes addressable as `<abilityName>:<skillId>`
|
|
60
|
+
* with no changes needed on the loading side at all. */
|
|
61
|
+
function namespacedSkillId(abilityName, skillId) {
|
|
62
|
+
// abilityName came off the ability's own package.json "name" — for a
|
|
63
|
+
// scoped npm name ("@company/pkg") this is two path segments, which is
|
|
64
|
+
// fine (SkillGarden namespaces however deep the nesting goes), but each
|
|
65
|
+
// segment still has to be a safe path component on its own: same
|
|
66
|
+
// '..'-as-a-path-segment guard TOOL_NAME_PATTERN/SKILL_ID_PATTERN's own
|
|
67
|
+
// comment above already explains the need for, just for a manifest's
|
|
68
|
+
// "name" field instead of a tool/skill declaration.
|
|
69
|
+
for (const segment of abilityName.split('/')) {
|
|
70
|
+
if (segment === '' || segment === '.' || segment === '..') {
|
|
71
|
+
throw new AbilityManifestError(`Ability name '${abilityName}' isn't safe to use as a skill namespace.`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return `${abilityName}/${skillId}`;
|
|
75
|
+
}
|
|
76
|
+
// A tool name (unlike a skill id) is a flat identifier, not a path — it
|
|
77
|
+
// has to satisfy TOOL_NAME_PATTERN on its own (it becomes both a file
|
|
78
|
+
// basename and a JS identifier via toCamelCase), so an ability's raw
|
|
79
|
+
// package.json "name" can't be used directly the way it can as a path
|
|
80
|
+
// segment for a skill (hyphens, "@scope/", dots aren't valid there).
|
|
81
|
+
// Collapsing every run of non-alphanumeric characters to a single "_"
|
|
82
|
+
// and guaranteeing a leading letter (TOOL_NAME_PATTERN requires one)
|
|
83
|
+
// always produces a safe result — never throws, unlike
|
|
84
|
+
// namespacedSkillId, since there's no filesystem path to escape here.
|
|
85
|
+
function sanitizeForToolName(name) {
|
|
86
|
+
const cleaned = name
|
|
87
|
+
.toLowerCase()
|
|
88
|
+
.replace(/[^a-z0-9]+/g, '_')
|
|
89
|
+
.replace(/^_+|_+$/g, '');
|
|
90
|
+
return cleaned.length > 0 && /^[a-z]/.test(cleaned) ? cleaned : `a_${cleaned}`;
|
|
91
|
+
}
|
|
92
|
+
/** Two abilities can each declare a tool with the same bare name too —
|
|
93
|
+
* but unlike a skill id, a tool's name is the literal, model-callable
|
|
94
|
+
* function name: run-agent.ts's own dedupeToolsByName keeps only the
|
|
95
|
+
* *last* same-named entry and silently drops the rest before the model
|
|
96
|
+
* ever sees them (its own doc comment: "a name collision would still
|
|
97
|
+
* show up twice in toolSchemas, confusing (or rejected outright by) a
|
|
98
|
+
* real model API"), so two same-named tools can never actually coexist
|
|
99
|
+
* as distinct, callable tools the way two same-named skills can — only
|
|
100
|
+
* namespacing the *name itself* avoids that silent drop. */
|
|
101
|
+
function namespacedToolName(abilityName, toolName) {
|
|
102
|
+
return `${sanitizeForToolName(abilityName)}__${toolName}`;
|
|
103
|
+
}
|
|
104
|
+
/** Whether `manifestToolFile` (a bare path straight off a manifest, e.g.
|
|
105
|
+
* "tools/web_search.ts") is the file an installed `installedToolName`
|
|
106
|
+
* (bare, or namespaced under `abilityName` on a collision — see
|
|
107
|
+
* namespacedToolName) actually came from. Used by upgradeAbility to
|
|
108
|
+
* match a provenance-recorded, possibly-namespaced name back to the
|
|
109
|
+
* right manifest entry — comparing the *installed* name against a
|
|
110
|
+
* freshly-recomputed candidate, rather than trying to strip a namespace
|
|
111
|
+
* prefix back off `installedToolName` textually, since "<ability>__"
|
|
112
|
+
* isn't a structural separator the way a skill id's "/" is: a bare tool
|
|
113
|
+
* name could legitimately contain "__" on its own, so string-stripping
|
|
114
|
+
* it back off would be ambiguous in a way recomputing forward and
|
|
115
|
+
* comparing never is. */
|
|
116
|
+
function matchesInstalledToolName(abilityName, manifestToolFile, installedToolName) {
|
|
117
|
+
const bareToolName = basename(manifestToolFile, '.ts');
|
|
118
|
+
return bareToolName === installedToolName || namespacedToolName(abilityName, bareToolName) === installedToolName;
|
|
119
|
+
}
|
|
50
120
|
function provenancePath(agentName) {
|
|
51
121
|
return join(agentDir(agentName), '.loopengine-abilities.json');
|
|
52
122
|
}
|
|
@@ -227,25 +297,36 @@ export async function installAbility(agentName, spec, options = {}) {
|
|
|
227
297
|
// tool, just applied ability-wide.
|
|
228
298
|
const toolNames = [];
|
|
229
299
|
for (const toolFile of toolFiles) {
|
|
230
|
-
const
|
|
231
|
-
if (!TOOL_NAME_PATTERN.test(
|
|
300
|
+
const bareToolName = basename(toolFile, '.ts');
|
|
301
|
+
if (!TOOL_NAME_PATTERN.test(bareToolName)) {
|
|
232
302
|
throw new AbilityManifestError(`Tool file '${toolFile}' doesn't name a valid tool (must be lowercase snake_case).`);
|
|
233
303
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
304
|
+
// The bare name if it's free; otherwise namespace under this
|
|
305
|
+
// ability's own name rather than refusing the install outright —
|
|
306
|
+
// see namespacedToolName's own doc comment for why this still
|
|
307
|
+
// leaves both tools genuinely callable, unlike a same-named actauth
|
|
308
|
+
// rule (below), which has no such option.
|
|
309
|
+
const installedToolName = existsSync(join(toolsDir, `${bareToolName}.ts`)) ? namespacedToolName(manifest.name, bareToolName) : bareToolName;
|
|
310
|
+
if (existsSync(join(toolsDir, `${installedToolName}.ts`))) {
|
|
311
|
+
throw new AbilityCollisionError(`agents/${agentName}/tools/${installedToolName}.ts already exists.`);
|
|
237
312
|
}
|
|
313
|
+
toolNames.push(installedToolName);
|
|
238
314
|
}
|
|
239
315
|
const skillIds = [];
|
|
240
316
|
for (const skillDir of skillDirs) {
|
|
241
|
-
const
|
|
242
|
-
if (!SKILL_ID_PATTERN.test(
|
|
317
|
+
const bareSkillId = basename(skillDir);
|
|
318
|
+
if (!SKILL_ID_PATTERN.test(bareSkillId)) {
|
|
243
319
|
throw new AbilityManifestError(`Skill directory '${skillDir}' doesn't name a valid skill id (must be lowercase, hyphen-separated).`);
|
|
244
320
|
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
321
|
+
// The bare id if it's free; otherwise namespace under this ability's
|
|
322
|
+
// own name rather than refusing the install outright — see
|
|
323
|
+
// namespacedSkillId's own doc comment for why this is safe to do
|
|
324
|
+
// (skills, unlike tools, don't need a single global namespace).
|
|
325
|
+
const installedSkillId = existsSync(join(skillsDirPath, bareSkillId)) ? namespacedSkillId(manifest.name, bareSkillId) : bareSkillId;
|
|
326
|
+
if (existsSync(join(skillsDirPath, installedSkillId))) {
|
|
327
|
+
throw new AbilityCollisionError(`agents/${agentName}/skills/${installedSkillId}/ already exists.`);
|
|
248
328
|
}
|
|
329
|
+
skillIds.push(installedSkillId);
|
|
249
330
|
}
|
|
250
331
|
const existingRuleNames = new Set(readActauthConfig(agentName).rules.map((r) => r.name));
|
|
251
332
|
for (const rule of rules) {
|
|
@@ -261,27 +342,51 @@ export async function installAbility(agentName, spec, options = {}) {
|
|
|
261
342
|
if (toolNames.length > 0) {
|
|
262
343
|
mkdirSync(toolsDir, { recursive: true });
|
|
263
344
|
const indexPath = join(toolsDir, 'index.ts');
|
|
264
|
-
for (const toolFile of toolFiles) {
|
|
265
|
-
const
|
|
345
|
+
for (const [i, toolFile] of toolFiles.entries()) {
|
|
346
|
+
const bareToolName = basename(toolFile, '.ts');
|
|
347
|
+
const installedToolName = toolNames[i];
|
|
266
348
|
const code = readFileSync(join(abilityDir, toolFile), 'utf8');
|
|
267
|
-
const destPath = join(toolsDir, `${
|
|
349
|
+
const destPath = join(toolsDir, `${installedToolName}.ts`);
|
|
268
350
|
writeFileSync(destPath, code);
|
|
269
|
-
contentHashes[`tools/${
|
|
270
|
-
|
|
351
|
+
contentHashes[`tools/${installedToolName}.ts`] = sha256(code);
|
|
352
|
+
// Plain case: the file's own export is imported and used as-is,
|
|
353
|
+
// exactly as before. Namespaced case: the file's own internal
|
|
354
|
+
// export name is untouched (still whatever the ability itself
|
|
355
|
+
// authored — importing it under that same bare name from two
|
|
356
|
+
// different abilities' files would be a duplicate top-level
|
|
357
|
+
// identifier), so the import gets aliased, and a small wrapper
|
|
358
|
+
// `const` overrides the actual model-facing `name` field, which
|
|
359
|
+
// the JS identifier alone has no effect on. See addToolToIndex's
|
|
360
|
+
// own doc comment for why `importSpecifier`/`arrayExpression` are
|
|
361
|
+
// separate parameters.
|
|
362
|
+
const sourceExportName = toCamelCase(bareToolName);
|
|
363
|
+
const namespaced = installedToolName !== bareToolName;
|
|
364
|
+
const arrayExpression = namespaced ? toCamelCase(installedToolName) : sourceExportName;
|
|
365
|
+
const importSpecifier = namespaced ? `${sourceExportName} as ${arrayExpression}Source` : sourceExportName;
|
|
366
|
+
const preamble = namespaced ? `const ${arrayExpression}: ToolDefinition = { ...${arrayExpression}Source, name: '${installedToolName}' }` : undefined;
|
|
271
367
|
if (existsSync(indexPath)) {
|
|
272
|
-
addToolToIndex(indexPath,
|
|
368
|
+
addToolToIndex(indexPath, installedToolName, importSpecifier, arrayExpression, preamble);
|
|
273
369
|
}
|
|
274
370
|
else {
|
|
275
|
-
|
|
371
|
+
const preambleBlock = preamble ? `${preamble}\n\n` : '';
|
|
372
|
+
writeFileSync(indexPath, `import type { ToolDefinition } from 'loopengine'\nimport { ${importSpecifier} } from './${installedToolName}.js'\n\n${preambleBlock}export const tools: ToolDefinition[] = [${arrayExpression}]\n`);
|
|
276
373
|
}
|
|
277
374
|
}
|
|
278
375
|
}
|
|
279
|
-
// Copy skill directories verbatim
|
|
280
|
-
|
|
281
|
-
|
|
376
|
+
// Copy skill directories verbatim — skillIds[i] is skillDirs[i]'s
|
|
377
|
+
// already-resolved installed id (bare, or namespaced under this
|
|
378
|
+
// ability's own name if the bare id collided — computed once, above,
|
|
379
|
+
// not recomputed here, so this loop can't disagree with the collision
|
|
380
|
+
// check that already ran against it).
|
|
381
|
+
for (const [i, skillDir] of skillDirs.entries()) {
|
|
382
|
+
const skillId = skillIds[i];
|
|
282
383
|
const srcPath = join(abilityDir, skillDir);
|
|
283
384
|
const destPath = join(skillsDirPath, skillId);
|
|
284
|
-
|
|
385
|
+
// dirname(destPath), not skillsDirPath — a namespaced skillId
|
|
386
|
+
// ("<abilityName>/<skillId>") needs its own intermediate directory
|
|
387
|
+
// created first; cpSync creates destPath itself but not necessarily
|
|
388
|
+
// parents beyond that.
|
|
389
|
+
mkdirSync(dirname(destPath), { recursive: true });
|
|
285
390
|
cpSync(srcPath, destPath, { recursive: true });
|
|
286
391
|
const skillMdPath = join(destPath, 'SKILL.md');
|
|
287
392
|
if (existsSync(skillMdPath)) {
|
|
@@ -397,8 +502,12 @@ export async function upgradeAbility(agentName, abilityName, options = {}) {
|
|
|
397
502
|
const newContentHashes = { ...record.contentHashes };
|
|
398
503
|
for (const toolName of record.tools) {
|
|
399
504
|
const relPath = `tools/${toolName}.ts`;
|
|
400
|
-
|
|
401
|
-
|
|
505
|
+
// matchesInstalledToolName, not a bare basename(f, '.ts') === toolName
|
|
506
|
+
// comparison — toolName may be namespaced (see namespacedToolName),
|
|
507
|
+
// and "<ability>__" isn't a structural separator safe to strip back
|
|
508
|
+
// off textually the way a skill id's "/" is.
|
|
509
|
+
const oldFile = (oldManifest.tools ?? []).find((f) => matchesInstalledToolName(abilityName, f, toolName));
|
|
510
|
+
const newFile = (newManifest.tools ?? []).find((f) => matchesInstalledToolName(abilityName, f, toolName));
|
|
402
511
|
if (!oldFile || !newFile) {
|
|
403
512
|
results.push({ path: relPath, status: 'unchanged' });
|
|
404
513
|
continue;
|
|
@@ -418,8 +527,13 @@ export async function upgradeAbility(agentName, abilityName, options = {}) {
|
|
|
418
527
|
}
|
|
419
528
|
for (const skillId of record.skills) {
|
|
420
529
|
const relPath = `skills/${skillId}/SKILL.md`;
|
|
421
|
-
|
|
422
|
-
|
|
530
|
+
// basename(skillId), not skillId itself — a namespaced install
|
|
531
|
+
// ("<abilityName>/<skillId>") still has to match against the
|
|
532
|
+
// manifest's own bare skill dir names, which never carry the
|
|
533
|
+
// namespace (that's only ever added at install time, on collision).
|
|
534
|
+
const bareSkillId = basename(skillId);
|
|
535
|
+
const oldSkillDir = (oldManifest.skills ?? []).find((s) => basename(s) === bareSkillId);
|
|
536
|
+
const newSkillDir = (newManifest.skills ?? []).find((s) => basename(s) === bareSkillId);
|
|
423
537
|
const oldFile = oldSkillDir ? join(oldDir, oldSkillDir, 'SKILL.md') : null;
|
|
424
538
|
const newFile = newSkillDir ? join(newDir, newSkillDir, 'SKILL.md') : null;
|
|
425
539
|
if (!oldFile || !newFile || !existsSync(oldFile) || !existsSync(newFile)) {
|
|
@@ -525,6 +639,20 @@ export function removeAbility(agentName, abilityName, force = false) {
|
|
|
525
639
|
}
|
|
526
640
|
rmSync(join(agentDir(agentName), 'skills', skillId), { recursive: true, force: true });
|
|
527
641
|
removed.push(`skills/${skillId}/`);
|
|
642
|
+
// A namespaced skillId ("<ability-name>/<id>", see namespacedSkillId)
|
|
643
|
+
// leaves its own now-empty "<ability-name>/" namespace directory
|
|
644
|
+
// behind — best-effort cleanup, not "refuse rather than guess": rmdir
|
|
645
|
+
// itself already refuses (ENOTEMPTY) if another skill from this same
|
|
646
|
+
// ability is still namespaced under it, which is exactly the case
|
|
647
|
+
// this should leave alone.
|
|
648
|
+
if (skillId.includes('/')) {
|
|
649
|
+
try {
|
|
650
|
+
rmdirSync(join(agentDir(agentName), 'skills', dirname(skillId)));
|
|
651
|
+
}
|
|
652
|
+
catch {
|
|
653
|
+
// Not empty, or already gone — either way, nothing to do.
|
|
654
|
+
}
|
|
655
|
+
}
|
|
528
656
|
}
|
|
529
657
|
for (const ruleName of record.actauthRules) {
|
|
530
658
|
try {
|
|
@@ -1123,10 +1123,19 @@ export const agentsConfigPageHtml = `<!doctype html>
|
|
|
1123
1123
|
}
|
|
1124
1124
|
|
|
1125
1125
|
function renderEnvRow(v) {
|
|
1126
|
+
// More than one ability declaring the same name doesn't get resolved
|
|
1127
|
+
// here — there's one .env per project, so both read whatever single
|
|
1128
|
+
// value ends up set, whether that's actually correct or a coincidence
|
|
1129
|
+
// (see env-admin.ts's own doc comment). Listing every ability instead
|
|
1130
|
+
// of just the first one seen at least makes that visible to whoever's
|
|
1131
|
+
// looking, instead of silently hiding all but one.
|
|
1132
|
+
var sharedNote = v.abilityNames.length > 1
|
|
1133
|
+
? ' <span class="hint" title="More than one installed ability declares this name — there is only one value for it project-wide, so check whether they actually need the same one.">(shared)</span>'
|
|
1134
|
+
: '';
|
|
1126
1135
|
return '<tr>' +
|
|
1127
1136
|
'<td><code>' + escapeHtml(v.name) + '</code></td>' +
|
|
1128
1137
|
'<td>' + escapeHtml(v.description || '') + '</td>' +
|
|
1129
|
-
'<td class="hint">' + escapeHtml(v.
|
|
1138
|
+
'<td class="hint">' + escapeHtml(v.abilityNames.join(', ')) + sharedNote + '</td>' +
|
|
1130
1139
|
'<td>' + (v.set ? '<span class="hint">set</span>' : '<span class="error">not set</span>') + '</td>' +
|
|
1131
1140
|
'<td><form class="add-source env-var-form" data-name="' + escapeHtml(v.name) + '">' +
|
|
1132
1141
|
'<input type="' + (v.secret ? 'password' : 'text') + '" name="value" placeholder="' + (v.set ? 'unchanged unless you type a new value' : 'value') + '" required>' +
|
|
@@ -1138,7 +1147,7 @@ export const agentsConfigPageHtml = `<!doctype html>
|
|
|
1138
1147
|
function renderEnvConfigHtml(vars) {
|
|
1139
1148
|
if (!vars.length) return '<p class="hint">No installed ability has declared any environment variables for this agent yet.</p>';
|
|
1140
1149
|
var rows = vars.map(renderEnvRow).join('');
|
|
1141
|
-
return '<table><thead><tr><th>Name</th><th>Description</th><th>
|
|
1150
|
+
return '<table><thead><tr><th>Name</th><th>Description</th><th>Required by</th><th>Status</th><th>Set value</th></tr></thead><tbody>' + rows + '</tbody></table>';
|
|
1142
1151
|
}
|
|
1143
1152
|
|
|
1144
1153
|
function envContentEl() {
|
package/dist/web/env-admin.d.ts
CHANGED
|
@@ -4,17 +4,36 @@ export interface DeclaredEnvVar {
|
|
|
4
4
|
name: string;
|
|
5
5
|
description?: string;
|
|
6
6
|
secret: boolean;
|
|
7
|
-
|
|
7
|
+
/** Every ability that declares this name, in install order — usually
|
|
8
|
+
* one, but see listDeclaredEnvVars's own doc comment for why this is
|
|
9
|
+
* an array, not a single name. */
|
|
10
|
+
abilityNames: string[];
|
|
8
11
|
set: boolean;
|
|
9
12
|
}
|
|
10
13
|
/** Every env var any ability installed for `agentName` declared it
|
|
11
|
-
* needs,
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
14
|
+
* needs, merged by name. There's one .env per *project*, not per agent
|
|
15
|
+
* or per ability, so this can't actually resolve a genuine cross-ability
|
|
16
|
+
* name collision (two abilities declaring, say, "API_KEY" for two
|
|
17
|
+
* unrelated services still both read whichever single value ends up
|
|
18
|
+
* set — there is no per-ability slot to give them) — unlike a tool or
|
|
19
|
+
* skill name collision (see ability-manager.ts's namespacedToolName/
|
|
20
|
+
* namespacedSkillId), an env var name is a literal `process.env.X`
|
|
21
|
+
* reference baked into the ability's own code, not a label loopengine's
|
|
22
|
+
* own generated glue controls, so there's nothing here to safely rename.
|
|
23
|
+
* What this *can* do, and used to not: surface every ability that
|
|
24
|
+
* declares a given name (`abilityNames`, plural) instead of silently
|
|
25
|
+
* keeping only the first one seen and hiding the rest — turns a
|
|
26
|
+
* collision from invisible into something a human looking at the
|
|
27
|
+
* Environment tab can actually notice and judge (a real conflict
|
|
28
|
+
* needing one ability's own env var renamed, or a coincidence that's
|
|
29
|
+
* actually fine because both intentionally share one real credential).
|
|
30
|
+
* `secret` is true if *any* declaring ability marked it secret — the
|
|
31
|
+
* safer default, since treating a real secret as non-secret because a
|
|
32
|
+
* different ability's own declaration happened to be checked first
|
|
33
|
+
* would be the one direction genuinely worth avoiding. `set` is read
|
|
34
|
+
* live off `process.env`, not cached, so it reflects whatever the last
|
|
35
|
+
* `setEnvVar` call — or a plain restart picking up `.env` — actually
|
|
36
|
+
* did. */
|
|
18
37
|
export declare function listDeclaredEnvVars(agentName: string): DeclaredEnvVar[];
|
|
19
38
|
/** Upserts `NAME=VALUE` into the project's `.env` file — preserving
|
|
20
39
|
* every other line (comments, blank lines, unrelated keys) — and
|
package/dist/web/env-admin.js
CHANGED
|
@@ -16,35 +16,53 @@ function provenancePath(agentName) {
|
|
|
16
16
|
return join(agentDir(agentName), '.loopengine-abilities.json');
|
|
17
17
|
}
|
|
18
18
|
/** Every env var any ability installed for `agentName` declared it
|
|
19
|
-
* needs,
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
19
|
+
* needs, merged by name. There's one .env per *project*, not per agent
|
|
20
|
+
* or per ability, so this can't actually resolve a genuine cross-ability
|
|
21
|
+
* name collision (two abilities declaring, say, "API_KEY" for two
|
|
22
|
+
* unrelated services still both read whichever single value ends up
|
|
23
|
+
* set — there is no per-ability slot to give them) — unlike a tool or
|
|
24
|
+
* skill name collision (see ability-manager.ts's namespacedToolName/
|
|
25
|
+
* namespacedSkillId), an env var name is a literal `process.env.X`
|
|
26
|
+
* reference baked into the ability's own code, not a label loopengine's
|
|
27
|
+
* own generated glue controls, so there's nothing here to safely rename.
|
|
28
|
+
* What this *can* do, and used to not: surface every ability that
|
|
29
|
+
* declares a given name (`abilityNames`, plural) instead of silently
|
|
30
|
+
* keeping only the first one seen and hiding the rest — turns a
|
|
31
|
+
* collision from invisible into something a human looking at the
|
|
32
|
+
* Environment tab can actually notice and judge (a real conflict
|
|
33
|
+
* needing one ability's own env var renamed, or a coincidence that's
|
|
34
|
+
* actually fine because both intentionally share one real credential).
|
|
35
|
+
* `secret` is true if *any* declaring ability marked it secret — the
|
|
36
|
+
* safer default, since treating a real secret as non-secret because a
|
|
37
|
+
* different ability's own declaration happened to be checked first
|
|
38
|
+
* would be the one direction genuinely worth avoiding. `set` is read
|
|
39
|
+
* live off `process.env`, not cached, so it reflects whatever the last
|
|
40
|
+
* `setEnvVar` call — or a plain restart picking up `.env` — actually
|
|
41
|
+
* did. */
|
|
26
42
|
export function listDeclaredEnvVars(agentName) {
|
|
27
43
|
const path = provenancePath(agentName);
|
|
28
44
|
if (!existsSync(path))
|
|
29
45
|
return [];
|
|
30
46
|
const provenance = JSON.parse(readFileSync(path, 'utf8'));
|
|
31
|
-
const
|
|
32
|
-
const result = [];
|
|
47
|
+
const byName = new Map();
|
|
33
48
|
for (const [abilityName, record] of Object.entries(provenance)) {
|
|
34
49
|
for (const decl of record.env) {
|
|
35
|
-
|
|
50
|
+
const existing = byName.get(decl.name);
|
|
51
|
+
if (existing) {
|
|
52
|
+
existing.abilityNames.push(abilityName);
|
|
53
|
+
existing.secret = existing.secret || decl.secret === true;
|
|
36
54
|
continue;
|
|
37
|
-
|
|
38
|
-
|
|
55
|
+
}
|
|
56
|
+
byName.set(decl.name, {
|
|
39
57
|
name: decl.name,
|
|
40
58
|
description: decl.description,
|
|
41
59
|
secret: decl.secret === true,
|
|
42
|
-
abilityName,
|
|
60
|
+
abilityNames: [abilityName],
|
|
43
61
|
set: process.env[decl.name] !== undefined,
|
|
44
62
|
});
|
|
45
63
|
}
|
|
46
64
|
}
|
|
47
|
-
return
|
|
65
|
+
return [...byName.values()];
|
|
48
66
|
}
|
|
49
67
|
// process.cwd(), matching exactly where bin/cli.ts's own runTsx passes
|
|
50
68
|
// --env-file-if-exists=.env (Node's own --env-file resolves relative to
|
|
@@ -37,18 +37,29 @@ export interface HttpToolSpec {
|
|
|
37
37
|
safe?: boolean;
|
|
38
38
|
}
|
|
39
39
|
export declare function toCamelCase(snakeCase: string): string;
|
|
40
|
-
export declare function addToolToIndex(toolsIndexPath: string, toolFileName: string,
|
|
41
|
-
/** addToolToIndex's own undo — removes the named tool's import line
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* since). No-ops (rather
|
|
50
|
-
*
|
|
51
|
-
|
|
40
|
+
export declare function addToolToIndex(toolsIndexPath: string, toolFileName: string, importSpecifier: string, arrayExpression?: string, preamble?: string): void;
|
|
41
|
+
/** addToolToIndex's own undo — removes the named tool's import line,
|
|
42
|
+
* wrapper `const` (if a namespaced install added one), and array entry.
|
|
43
|
+
* Best-effort, not "refuse rather than guess" like addToolToIndex: this
|
|
44
|
+
* only ever runs against a tool ability-manager.ts itself installed (so
|
|
45
|
+
* the exact shape addToolToIndex wrote is known), and the caller
|
|
46
|
+
* (removeAbility) has already deleted the tool's .ts file — leaving a
|
|
47
|
+
* stale entry behind, the previous behavior, means a dangling import
|
|
48
|
+
* that fails the next build, which is strictly worse than a no-op here
|
|
49
|
+
* if the file turns out not to match (hand-edited since). No-ops (rather
|
|
50
|
+
* than throwing) when the file is missing entirely, or the expected
|
|
51
|
+
* import/array-entry shape isn't found.
|
|
52
|
+
*
|
|
53
|
+
* `arrayExpression` (not the raw import specifier) — same value
|
|
54
|
+
* addToolToIndex's own `arrayExpression` was called with, since that's
|
|
55
|
+
* what actually appears in the array literal and (for a namespaced
|
|
56
|
+
* install) names the wrapper `const` too. The import line is matched by
|
|
57
|
+
* `toolFileName` alone, not by the exact imported binding: a namespaced
|
|
58
|
+
* install aliases its import (`X as Y`), so matching on the file path —
|
|
59
|
+
* which uniquely identifies this one tool either way — is what makes
|
|
60
|
+
* this work for both shapes without the caller having to say which one
|
|
61
|
+
* it originally was. */
|
|
62
|
+
export declare function removeToolFromIndex(toolsIndexPath: string, toolFileName: string, arrayExpression: string): void;
|
|
52
63
|
/** Generates agents/<agentName>/tools/<spec.name>.ts, registers it in
|
|
53
64
|
* that agent's tools/index.ts, then imports the file it just wrote and
|
|
54
65
|
* returns the real, callable ToolDefinition object — so the route
|
|
@@ -179,13 +179,29 @@ ${responseHandling} },
|
|
|
179
179
|
// across every real example in this repo (agents/customer-service/
|
|
180
180
|
// tools/index.ts, create-loopengine's own template) — a handful of
|
|
181
181
|
// named imports, then one `export const tools: ToolDefinition[] = [...]`
|
|
182
|
-
// array literal. This patches that exact shape with
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
|
|
182
|
+
// array literal. This patches that exact shape with small string edits
|
|
183
|
+
// (one new import line, one new array entry, and — only for a namespaced
|
|
184
|
+
// ability install, see ability-manager.ts's namespacedToolName — one
|
|
185
|
+
// `const` line between them) rather than a full TS-AST rewrite; if the
|
|
186
|
+
// file has been hand-edited into something this pattern doesn't
|
|
187
|
+
// recognize, it refuses instead of risking corrupting it — same "refuse
|
|
188
|
+
// rather than guess" discipline agent-file-admin.ts's own doc comment
|
|
189
|
+
// already establishes for index.ts edits.
|
|
190
|
+
//
|
|
191
|
+
// `importSpecifier` and `arrayExpression` are deliberately separate
|
|
192
|
+
// parameters, not always the same string: the common case (this
|
|
193
|
+
// function's only caller until ability-manager.ts's own namespacing)
|
|
194
|
+
// has them identical (a bare `import { X }`, then bare `X` in the
|
|
195
|
+
// array), but a namespaced tool install needs an *aliased* import (two
|
|
196
|
+
// abilities' own files can both export a same-named top-level `const` —
|
|
197
|
+
// e.g. both literally `webSearch` — which would collide as two
|
|
198
|
+
// identically-named imports in this one file even though the source
|
|
199
|
+
// files differ) feeding into a small wrapper `const` that overrides the
|
|
200
|
+
// object's own `name` field (aliasing the import only renames the JS
|
|
201
|
+
// binding, never the `ToolDefinition.name` string the model actually
|
|
202
|
+
// calls by) — `preamble` is that wrapper line, and `arrayExpression` is
|
|
203
|
+
// its name, not the raw import's.
|
|
204
|
+
export function addToolToIndex(toolsIndexPath, toolFileName, importSpecifier, arrayExpression = importSpecifier, preamble) {
|
|
189
205
|
const source = readFileSync(toolsIndexPath, 'utf8');
|
|
190
206
|
const arrayMatch = source.match(/export const tools: ToolDefinition\[\] = \[([^\]]*)\]/);
|
|
191
207
|
if (!arrayMatch) {
|
|
@@ -201,40 +217,54 @@ export function addToolToIndex(toolsIndexPath, toolFileName, exportName) {
|
|
|
201
217
|
.split(',')
|
|
202
218
|
.map((e) => e.trim())
|
|
203
219
|
.filter(Boolean);
|
|
204
|
-
if (arrayEntries.includes(
|
|
220
|
+
if (arrayEntries.includes(arrayExpression))
|
|
205
221
|
return;
|
|
206
|
-
const importLine = `import { ${
|
|
222
|
+
const importLine = `import { ${importSpecifier} } from './${toolFileName}.js'\n`;
|
|
207
223
|
const lastImportMatch = [...source.matchAll(/^import .+\n/gm)].pop();
|
|
208
224
|
const insertAt = lastImportMatch ? lastImportMatch.index + lastImportMatch[0].length : 0;
|
|
209
|
-
const
|
|
210
|
-
const
|
|
225
|
+
const preambleBlock = preamble ? `${preamble}\n` : '';
|
|
226
|
+
const withImport = source.slice(0, insertAt) + importLine + preambleBlock + source.slice(insertAt);
|
|
227
|
+
const newEntries = [...arrayEntries, arrayExpression].join(', ');
|
|
211
228
|
const withEntry = withImport.replace(/export const tools: ToolDefinition\[\] = \[([^\]]*)\]/, `export const tools: ToolDefinition[] = [${newEntries}]`);
|
|
212
229
|
writeFileSync(toolsIndexPath, withEntry);
|
|
213
230
|
}
|
|
214
|
-
/** addToolToIndex's own undo — removes the named tool's import line
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
* since). No-ops (rather
|
|
223
|
-
*
|
|
224
|
-
|
|
231
|
+
/** addToolToIndex's own undo — removes the named tool's import line,
|
|
232
|
+
* wrapper `const` (if a namespaced install added one), and array entry.
|
|
233
|
+
* Best-effort, not "refuse rather than guess" like addToolToIndex: this
|
|
234
|
+
* only ever runs against a tool ability-manager.ts itself installed (so
|
|
235
|
+
* the exact shape addToolToIndex wrote is known), and the caller
|
|
236
|
+
* (removeAbility) has already deleted the tool's .ts file — leaving a
|
|
237
|
+
* stale entry behind, the previous behavior, means a dangling import
|
|
238
|
+
* that fails the next build, which is strictly worse than a no-op here
|
|
239
|
+
* if the file turns out not to match (hand-edited since). No-ops (rather
|
|
240
|
+
* than throwing) when the file is missing entirely, or the expected
|
|
241
|
+
* import/array-entry shape isn't found.
|
|
242
|
+
*
|
|
243
|
+
* `arrayExpression` (not the raw import specifier) — same value
|
|
244
|
+
* addToolToIndex's own `arrayExpression` was called with, since that's
|
|
245
|
+
* what actually appears in the array literal and (for a namespaced
|
|
246
|
+
* install) names the wrapper `const` too. The import line is matched by
|
|
247
|
+
* `toolFileName` alone, not by the exact imported binding: a namespaced
|
|
248
|
+
* install aliases its import (`X as Y`), so matching on the file path —
|
|
249
|
+
* which uniquely identifies this one tool either way — is what makes
|
|
250
|
+
* this work for both shapes without the caller having to say which one
|
|
251
|
+
* it originally was. */
|
|
252
|
+
export function removeToolFromIndex(toolsIndexPath, toolFileName, arrayExpression) {
|
|
225
253
|
if (!existsSync(toolsIndexPath))
|
|
226
254
|
return;
|
|
227
|
-
|
|
228
|
-
const importLineRe = new RegExp(`^import \\{
|
|
229
|
-
|
|
230
|
-
const
|
|
255
|
+
let source = readFileSync(toolsIndexPath, 'utf8');
|
|
256
|
+
const importLineRe = new RegExp(`^import \\{ [^}]+ \\} from '\\./${toolFileName}\\.js'\\n`, 'm');
|
|
257
|
+
source = source.replace(importLineRe, '');
|
|
258
|
+
const preambleRe = new RegExp(`^const ${arrayExpression}: ToolDefinition = \\{[^\\n]*\\}\\n`, 'm');
|
|
259
|
+
source = source.replace(preambleRe, '');
|
|
260
|
+
const arrayMatch = source.match(/export const tools: ToolDefinition\[\] = \[([^\]]*)\]/);
|
|
231
261
|
if (!arrayMatch)
|
|
232
262
|
return;
|
|
233
263
|
const remainingEntries = arrayMatch[1]
|
|
234
264
|
.split(',')
|
|
235
265
|
.map((e) => e.trim())
|
|
236
|
-
.filter((e) => e && e !==
|
|
237
|
-
const withEntry =
|
|
266
|
+
.filter((e) => e && e !== arrayExpression);
|
|
267
|
+
const withEntry = source.replace(/export const tools: ToolDefinition\[\] = \[([^\]]*)\]/, `export const tools: ToolDefinition[] = [${remainingEntries.join(', ')}]`);
|
|
238
268
|
writeFileSync(toolsIndexPath, withEntry);
|
|
239
269
|
}
|
|
240
270
|
// The spec is persisted alongside the generated .ts file, under the same
|