create-loopengine 0.1.12 → 0.1.14
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/README.md +36 -0
- package/dist/cli.d.ts +27 -0
- package/dist/cli.js +169 -1
- package/package.json +1 -1
- package/template/adapters/http.ts +140 -10
- package/template/package.json +2 -2
package/README.md
CHANGED
|
@@ -51,6 +51,7 @@ agents/example-agent.ts Starter agent: persona, one tool, one permission rule
|
|
|
51
51
|
adapters/http.ts HTTP API (streaming + non-streaming), routes by agent name
|
|
52
52
|
adapters/cli.ts Command-line adapter
|
|
53
53
|
.env.example ANTHROPIC_API_KEY / OPENAI_API_KEY / REDIS_URL
|
|
54
|
+
.create-loopengine.json Which version this project was scaffolded from — see "Upgrading" below
|
|
54
55
|
```
|
|
55
56
|
|
|
56
57
|
The registry is multi-agent-capable from the start — seeded with one
|
|
@@ -84,6 +85,41 @@ working example. Not wired in here by default: it needs the `composio` CLI
|
|
|
84
85
|
installed and an authenticated account (`composio link <toolkit>`) first,
|
|
85
86
|
so it's a deliberate next step, not part of this starter agent.
|
|
86
87
|
|
|
88
|
+
## Upgrading
|
|
89
|
+
|
|
90
|
+
`npm install loopengine@latest` alone only picks up library-side changes
|
|
91
|
+
— `adapters/http.ts`, `adapters/cli.ts`, and `agent-registry.ts` are
|
|
92
|
+
copied into your project once at scaffold time and never touched by npm
|
|
93
|
+
again, so template improvements (new routes, bug fixes) don't reach an
|
|
94
|
+
existing project on their own. `upgrade` closes that gap with a real
|
|
95
|
+
three-way merge — the same technique `git merge` uses — so your own
|
|
96
|
+
edits to those files survive:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
npx create-loopengine@latest upgrade
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Run from inside your project. It merges the current template against
|
|
103
|
+
whichever `create-loopengine` version your project was originally
|
|
104
|
+
scaffolded from (recorded in `.create-loopengine.json`, written
|
|
105
|
+
automatically at scaffold time — pass `--from <version>` if that file is
|
|
106
|
+
missing, e.g. for a project scaffolded before this command existed).
|
|
107
|
+
Each of the five template-owned files gets one of:
|
|
108
|
+
|
|
109
|
+
- **unchanged** — the template never touched this file between your
|
|
110
|
+
version and the latest; nothing to merge, nothing overwritten.
|
|
111
|
+
- **updated** — merged cleanly; your own edits and the template's own
|
|
112
|
+
changes didn't overlap.
|
|
113
|
+
- **conflict** — you edited the same lines the template changed. The
|
|
114
|
+
file is written with real `<<<<<<<`/`=======`/`>>>>>>>` markers, same
|
|
115
|
+
as any git merge conflict — resolve them by hand, then `npx tsc
|
|
116
|
+
--noEmit` to confirm it builds.
|
|
117
|
+
|
|
118
|
+
`upgrade` never touches `agents/`, `README.md`, `.env.example`, or
|
|
119
|
+
`package.json`'s own dependency versions — run `npm install
|
|
120
|
+
loopengine@latest` (and `actauth`/`skillgarden` if you use them directly)
|
|
121
|
+
separately to pick those up.
|
|
122
|
+
|
|
87
123
|
## Status
|
|
88
124
|
|
|
89
125
|
Verified end to end against the real published packages: `npm create
|
package/dist/cli.d.ts
CHANGED
|
@@ -6,4 +6,31 @@ export interface ScaffoldOptions {
|
|
|
6
6
|
templateDir?: string;
|
|
7
7
|
}
|
|
8
8
|
export declare function scaffold(options: ScaffoldOptions): string;
|
|
9
|
+
export type UpgradeFileStatus = 'updated' | 'unchanged' | 'conflict' | 'skipped';
|
|
10
|
+
export interface UpgradeFileResult {
|
|
11
|
+
file: string;
|
|
12
|
+
status: UpgradeFileStatus;
|
|
13
|
+
}
|
|
14
|
+
export interface UpgradeResult {
|
|
15
|
+
fromVersion: string;
|
|
16
|
+
toVersion: string;
|
|
17
|
+
files: UpgradeFileResult[];
|
|
18
|
+
}
|
|
19
|
+
export interface UpgradeOptions {
|
|
20
|
+
/** Defaults to process.cwd(). */
|
|
21
|
+
projectDir?: string;
|
|
22
|
+
/** Overrides the version recorded in .create-loopengine.json — required
|
|
23
|
+
* if that file is missing (a project scaffolded before this command
|
|
24
|
+
* existed) or was removed. */
|
|
25
|
+
from?: string;
|
|
26
|
+
/** Test-only override for the "current" template — defaults to this
|
|
27
|
+
* CLI's own bundled template/, same as ScaffoldOptions.templateDir. */
|
|
28
|
+
currentTemplateDir?: string;
|
|
29
|
+
/** Resolves the historical template directory for a given
|
|
30
|
+
* create-loopengine version — defaults to fetchPublishedTemplateDir
|
|
31
|
+
* (a real `npm pack`). Test-only seam to avoid hitting the real
|
|
32
|
+
* registry for a fixture "old" template. */
|
|
33
|
+
resolveBaseTemplateDir?: (version: string) => string;
|
|
34
|
+
}
|
|
35
|
+
export declare function upgradeProject(options?: UpgradeOptions): UpgradeResult;
|
|
9
36
|
export declare function main(argv?: string[]): void;
|
package/dist/cli.js
CHANGED
|
@@ -1,11 +1,23 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
3
5
|
import { dirname, join } from 'node:path';
|
|
4
6
|
import { fileURLToPath } from 'node:url';
|
|
5
7
|
// Template ships in `template/` next to `dist/`, not inside it — it's a
|
|
6
8
|
// real, buildable project (its own package.json/tsconfig), not TypeScript
|
|
7
9
|
// source belonging to this CLI.
|
|
8
10
|
const DEFAULT_TEMPLATE_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'template');
|
|
11
|
+
// This CLI's own version — embedded in a scaffolded project's own
|
|
12
|
+
// .create-loopengine.json (see scaffold() below) as the future upgrade
|
|
13
|
+
// command's merge base, and used again here as the "to" version once
|
|
14
|
+
// upgradeProject() actually runs. Read at runtime rather than baked in
|
|
15
|
+
// at build time so a locally-linked/packed build always reports its own
|
|
16
|
+
// real package.json, not a stale constant.
|
|
17
|
+
function ownVersion() {
|
|
18
|
+
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
|
|
19
|
+
return JSON.parse(readFileSync(pkgPath, 'utf8')).version;
|
|
20
|
+
}
|
|
9
21
|
export function scaffold(options) {
|
|
10
22
|
const { name } = options;
|
|
11
23
|
const templateDir = options.templateDir ?? DEFAULT_TEMPLATE_DIR;
|
|
@@ -25,13 +37,169 @@ export function scaffold(options) {
|
|
|
25
37
|
const readmePath = join(destination, 'README.md');
|
|
26
38
|
const readme = readFileSync(readmePath, 'utf8').replace('__PROJECT_NAME__', name);
|
|
27
39
|
writeFileSync(readmePath, readme);
|
|
40
|
+
// Records which create-loopengine version this project's copied,
|
|
41
|
+
// hand-editable files (adapters/http.ts, agent-registry.ts, ...) last
|
|
42
|
+
// matched — the merge base `upgrade` needs to pull in template changes
|
|
43
|
+
// later without clobbering whatever's been hand-edited since. Tracked
|
|
44
|
+
// like package.json/lockfiles (committed, not gitignored): it's project
|
|
45
|
+
// metadata, not a build artifact.
|
|
46
|
+
writeFileSync(join(destination, '.create-loopengine.json'), JSON.stringify({ version: ownVersion() }, null, 2) + '\n');
|
|
28
47
|
return destination;
|
|
29
48
|
}
|
|
49
|
+
// ---- upgrade: pulls template improvements into an already-scaffolded
|
|
50
|
+
// project's own copied files (adapters/http.ts, adapters/cli.ts,
|
|
51
|
+
// agent-registry.ts, tsconfig.json, .gitignore) via a real three-way
|
|
52
|
+
// merge — the same technique `git merge`/`rebase` use, applied here
|
|
53
|
+
// because those files are meant to be hand-edited after scaffolding, not
|
|
54
|
+
// re-imported from a package the way core library code is. See
|
|
55
|
+
// scaffold()'s own .create-loopengine.json write above for the "base"
|
|
56
|
+
// version this merge starts from. ----
|
|
57
|
+
// templatePath is where the file lives inside a template/ directory
|
|
58
|
+
// (base or current); projectPath is where the *scaffolded* copy lives —
|
|
59
|
+
// only differs for _gitignore, which scaffold() renames to .gitignore
|
|
60
|
+
// (see its own comment for why the template can't ship it under that
|
|
61
|
+
// name directly). Deliberately narrow: agents/example-agent/** stops
|
|
62
|
+
// being "template" the instant someone builds a real agent on top of it,
|
|
63
|
+
// and package.json's own dependency versions are a separate, simpler
|
|
64
|
+
// `npm install loopengine@latest` left to the operator rather than
|
|
65
|
+
// merged here.
|
|
66
|
+
const UPGRADE_CANDIDATE_FILES = [
|
|
67
|
+
{ templatePath: 'adapters/http.ts', projectPath: 'adapters/http.ts' },
|
|
68
|
+
{ templatePath: 'adapters/cli.ts', projectPath: 'adapters/cli.ts' },
|
|
69
|
+
{ templatePath: 'agent-registry.ts', projectPath: 'agent-registry.ts' },
|
|
70
|
+
{ templatePath: 'tsconfig.json', projectPath: 'tsconfig.json' },
|
|
71
|
+
{ templatePath: '_gitignore', projectPath: '.gitignore' },
|
|
72
|
+
];
|
|
73
|
+
// npm keeps every published tarball forever, so the historical template
|
|
74
|
+
// for *any* previously-released version is always fetchable this way —
|
|
75
|
+
// no separate snapshot storage, no git-tag dependency. `npm pack`
|
|
76
|
+
// extracts to a `package/` directory inside whatever `--pack-destination`
|
|
77
|
+
// it's given, same as every other real-package verification already done
|
|
78
|
+
// this way elsewhere in this project's own test suite.
|
|
79
|
+
function fetchPublishedTemplateDir(version) {
|
|
80
|
+
const workDir = mkdtempSync(join(tmpdir(), 'create-loopengine-upgrade-base-'));
|
|
81
|
+
execFileSync('npm', ['pack', `create-loopengine@${version}`, '--pack-destination', workDir], { stdio: 'pipe' });
|
|
82
|
+
const tarball = readdirSync(workDir).find((f) => f.endsWith('.tgz'));
|
|
83
|
+
if (!tarball) {
|
|
84
|
+
throw new Error(`Could not fetch create-loopengine@${version} from npm — check the version exists and you have network access.`);
|
|
85
|
+
}
|
|
86
|
+
// Absolute path, not the bare filename readdirSync returns — tar
|
|
87
|
+
// resolves a relative first argument against the calling process's own
|
|
88
|
+
// cwd (wherever `create-loopengine upgrade` was actually run from), not
|
|
89
|
+
// workDir, even with -C set (-C only affects where extraction lands).
|
|
90
|
+
// Confirmed live: this failed with a wrong-directory ENOENT.
|
|
91
|
+
execFileSync('tar', ['-xzf', join(workDir, tarball), '-C', workDir], { stdio: 'pipe' });
|
|
92
|
+
return join(workDir, 'package', 'template');
|
|
93
|
+
}
|
|
94
|
+
// git merge-file's own exit code *is* its conflict count (0 = clean),
|
|
95
|
+
// not a pass/fail signal — execFileSync throws on any non-zero exit, so
|
|
96
|
+
// a real conflict has to be told apart from git genuinely failing to
|
|
97
|
+
// run (not installed, path error: no `status`, an `ENOENT`-shaped error
|
|
98
|
+
// instead) by checking for a numeric `status` on the caught error.
|
|
99
|
+
// Operates on `minePath` in place (git merge-file's own contract) — always
|
|
100
|
+
// called with a disposable temp copy, never the real project file
|
|
101
|
+
// directly, so the caller decides whether/how to apply the result.
|
|
102
|
+
function threeWayMerge(minePath, basePath, theirsPath) {
|
|
103
|
+
let conflicted = false;
|
|
104
|
+
try {
|
|
105
|
+
execFileSync('git', ['merge-file', '--diff3', '-L', 'mine', '-L', 'base', '-L', 'latest', minePath, basePath, theirsPath], {
|
|
106
|
+
stdio: 'pipe',
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
if (err && typeof err === 'object' && 'status' in err && typeof err.status === 'number') {
|
|
111
|
+
conflicted = true;
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
throw new Error(`git merge-file failed — is git installed and on PATH? (${err instanceof Error ? err.message : String(err)})`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return { merged: readFileSync(minePath, 'utf8'), conflicted };
|
|
118
|
+
}
|
|
119
|
+
function upgradeFile(candidate, baseTemplateDir, currentTemplateDir, projectDir) {
|
|
120
|
+
const projectFilePath = join(projectDir, candidate.projectPath);
|
|
121
|
+
const baseFilePath = join(baseTemplateDir, candidate.templatePath);
|
|
122
|
+
const theirsFilePath = join(currentTemplateDir, candidate.templatePath);
|
|
123
|
+
if (!existsSync(projectFilePath) || !existsSync(baseFilePath) || !existsSync(theirsFilePath)) {
|
|
124
|
+
return { file: candidate.projectPath, status: 'skipped' };
|
|
125
|
+
}
|
|
126
|
+
const base = readFileSync(baseFilePath, 'utf8');
|
|
127
|
+
const theirs = readFileSync(theirsFilePath, 'utf8');
|
|
128
|
+
if (base === theirs) {
|
|
129
|
+
// The template never touched this file between the recorded version
|
|
130
|
+
// and now — nothing to merge, and importantly nothing to overwrite,
|
|
131
|
+
// so a hand-edit here is never even diffed against, let alone at risk.
|
|
132
|
+
return { file: candidate.projectPath, status: 'unchanged' };
|
|
133
|
+
}
|
|
134
|
+
const mine = readFileSync(projectFilePath, 'utf8');
|
|
135
|
+
const scratchDir = mkdtempSync(join(tmpdir(), 'create-loopengine-merge-'));
|
|
136
|
+
const mineTmp = join(scratchDir, 'mine');
|
|
137
|
+
const baseTmp = join(scratchDir, 'base');
|
|
138
|
+
const theirsTmp = join(scratchDir, 'theirs');
|
|
139
|
+
writeFileSync(mineTmp, mine);
|
|
140
|
+
writeFileSync(baseTmp, base);
|
|
141
|
+
writeFileSync(theirsTmp, theirs);
|
|
142
|
+
const { merged, conflicted } = threeWayMerge(mineTmp, baseTmp, theirsTmp);
|
|
143
|
+
rmSync(scratchDir, { recursive: true, force: true });
|
|
144
|
+
writeFileSync(projectFilePath, merged);
|
|
145
|
+
return { file: candidate.projectPath, status: conflicted ? 'conflict' : 'updated' };
|
|
146
|
+
}
|
|
147
|
+
export function upgradeProject(options = {}) {
|
|
148
|
+
const projectDir = options.projectDir ?? process.cwd();
|
|
149
|
+
const currentTemplateDir = options.currentTemplateDir ?? DEFAULT_TEMPLATE_DIR;
|
|
150
|
+
const toVersion = ownVersion();
|
|
151
|
+
const provenancePath = join(projectDir, '.create-loopengine.json');
|
|
152
|
+
let fromVersion = options.from;
|
|
153
|
+
if (!fromVersion) {
|
|
154
|
+
if (!existsSync(provenancePath)) {
|
|
155
|
+
throw new Error(`No .create-loopengine.json in ${projectDir} — this project was scaffolded before \`upgrade\` existed, or the file was removed. Pass --from <version> to specify which create-loopengine version it was originally scaffolded from.`);
|
|
156
|
+
}
|
|
157
|
+
const provenance = JSON.parse(readFileSync(provenancePath, 'utf8'));
|
|
158
|
+
if (!provenance.version) {
|
|
159
|
+
throw new Error(`${provenancePath} is missing its "version" field — pass --from <version> instead.`);
|
|
160
|
+
}
|
|
161
|
+
fromVersion = provenance.version;
|
|
162
|
+
}
|
|
163
|
+
const resolveBase = options.resolveBaseTemplateDir ?? fetchPublishedTemplateDir;
|
|
164
|
+
const baseTemplateDir = resolveBase(fromVersion);
|
|
165
|
+
const files = UPGRADE_CANDIDATE_FILES.map((candidate) => upgradeFile(candidate, baseTemplateDir, currentTemplateDir, projectDir));
|
|
166
|
+
writeFileSync(provenancePath, JSON.stringify({ version: toVersion }, null, 2) + '\n');
|
|
167
|
+
return { fromVersion, toVersion, files };
|
|
168
|
+
}
|
|
169
|
+
function parseUpgradeArgs(argv) {
|
|
170
|
+
let from;
|
|
171
|
+
for (let i = 0; i < argv.length; i++) {
|
|
172
|
+
if (argv[i] === '--from')
|
|
173
|
+
from = argv[++i];
|
|
174
|
+
}
|
|
175
|
+
return { from };
|
|
176
|
+
}
|
|
177
|
+
function printUpgradeSummary(result) {
|
|
178
|
+
console.log(`Upgrading create-loopengine@${result.fromVersion} -> @${result.toVersion}`);
|
|
179
|
+
console.log();
|
|
180
|
+
for (const f of result.files) {
|
|
181
|
+
const label = f.status === 'updated' ? 'updated ' : f.status === 'conflict' ? 'conflict ' : f.status === 'unchanged' ? 'unchanged' : 'skipped ';
|
|
182
|
+
console.log(` ${label} ${f.file}`);
|
|
183
|
+
}
|
|
184
|
+
console.log();
|
|
185
|
+
const conflicted = result.files.filter((f) => f.status === 'conflict');
|
|
186
|
+
if (conflicted.length > 0) {
|
|
187
|
+
console.log(`${conflicted.length} file(s) have <<<<<<< conflict markers to resolve by hand before this will build.`);
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
console.log('No conflicts. Run `npm install loopengine@latest` to pick up any library-side changes too.');
|
|
191
|
+
}
|
|
192
|
+
}
|
|
30
193
|
function parseArgs(argv) {
|
|
31
194
|
return { name: argv[0] ?? 'my-agents' };
|
|
32
195
|
}
|
|
33
196
|
export function main(argv = process.argv.slice(2)) {
|
|
34
197
|
try {
|
|
198
|
+
if (argv[0] === 'upgrade') {
|
|
199
|
+
const { from } = parseUpgradeArgs(argv.slice(1));
|
|
200
|
+
printUpgradeSummary(upgradeProject({ from }));
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
35
203
|
const { name } = parseArgs(argv);
|
|
36
204
|
const destination = scaffold({ name });
|
|
37
205
|
console.log(`Created ${destination}/`);
|
package/package.json
CHANGED
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
AgentEditNotSupportedError,
|
|
44
44
|
AgentFileNotFoundError,
|
|
45
45
|
type AgentEditResult,
|
|
46
|
+
type AgentEditableFields,
|
|
46
47
|
createSessionStore,
|
|
47
48
|
createCheckpointStore,
|
|
48
49
|
type TurnCheckpoint,
|
|
@@ -79,6 +80,10 @@ import {
|
|
|
79
80
|
deleteSkill,
|
|
80
81
|
SkillInvalidIdError,
|
|
81
82
|
SkillNotFoundError,
|
|
83
|
+
listSkillgardenCatalog,
|
|
84
|
+
readSkillgardenCatalogEntry,
|
|
85
|
+
addSkillgardenSkillToAgent,
|
|
86
|
+
SkillgardenUnavailableError,
|
|
82
87
|
readActauthConfig,
|
|
83
88
|
addActauthRule,
|
|
84
89
|
updateActauthRule,
|
|
@@ -314,9 +319,14 @@ async function describeAgent(entry: RegistryEntry): Promise<Record<string, unkno
|
|
|
314
319
|
// configured for *this* agent, so the config page's own Skills tab
|
|
315
320
|
// deliberately doesn't mix it into skills/skillsDirs below.
|
|
316
321
|
const skillsDirs = config.skillsDirs ?? [`agents/${config.name}/skills`]
|
|
317
|
-
|
|
322
|
+
// Same defaults, same reasoning, as loopengine's own run-agent.ts
|
|
323
|
+
// SkillGarden/BudgetTracker construction — kept in sync there and here
|
|
324
|
+
// since this is what a "Skills"/"Limits & budgets" tab shows an
|
|
325
|
+
// operator, and it has to describe what runAgent() would actually
|
|
326
|
+
// resolve, not a second guess (see this function's own header comment).
|
|
327
|
+
const skillGarden = new SkillGarden({ dirs: skillsDirs, indexBudgetTokens: config.skillIndexBudgetTokens ?? 2000 })
|
|
318
328
|
const skillIndex = skillGarden.buildIndex().included
|
|
319
|
-
const systemSkillGarden = new SkillGarden({ dirs: [systemSkillsDir], indexBudgetTokens: config.skillIndexBudgetTokens ??
|
|
329
|
+
const systemSkillGarden = new SkillGarden({ dirs: [systemSkillsDir], indexBudgetTokens: config.skillIndexBudgetTokens ?? 2000 })
|
|
320
330
|
const systemSkillIndex = systemSkillGarden.buildIndex().included
|
|
321
331
|
|
|
322
332
|
return {
|
|
@@ -326,8 +336,8 @@ async function describeAgent(entry: RegistryEntry): Promise<Record<string, unkno
|
|
|
326
336
|
? { provider: config.model.provider, model: config.model.model, maxTokens: config.model.maxTokens }
|
|
327
337
|
: 'custom (module exports its own createModelCall)',
|
|
328
338
|
maxTurns: config.maxTurns ?? 25,
|
|
329
|
-
contextBudgetTokens: config.contextBudgetTokens ??
|
|
330
|
-
skillIndexBudgetTokens: config.skillIndexBudgetTokens ??
|
|
339
|
+
contextBudgetTokens: config.contextBudgetTokens ?? 100000,
|
|
340
|
+
skillIndexBudgetTokens: config.skillIndexBudgetTokens ?? 2000,
|
|
331
341
|
skillsDirs: skillsDirs,
|
|
332
342
|
skills: skillIndex.map((s) => ({ name: s.name, description: s.description })),
|
|
333
343
|
systemSkills: systemSkillIndex.map((s) => ({ name: s.name, description: s.description })),
|
|
@@ -644,6 +654,33 @@ function parseAgentTemplateOptions(body: Record<string, unknown>): { ok: true; v
|
|
|
644
654
|
return { ok: true, value: options }
|
|
645
655
|
}
|
|
646
656
|
|
|
657
|
+
// The Overview tab's "Limits & budgets" edit form — kept separate from
|
|
658
|
+
// parseAgentTemplateOptions since that one's shape (AgentTemplateOptions)
|
|
659
|
+
// is shared with handleCreateAgent/scaffoldAgent, which has no notion of
|
|
660
|
+
// these three at all (a freshly scaffolded agent always starts on
|
|
661
|
+
// loopengine's own defaults — see AgentConfig's own doc comments). Each
|
|
662
|
+
// of the three is optional and independent, same as systemPrompt/model
|
|
663
|
+
// above.
|
|
664
|
+
function parsePositiveInt(value: unknown, field: string): { ok: true; value: number | undefined } | { ok: false; error: string } {
|
|
665
|
+
if (value === undefined || value === null) return { ok: true, value: undefined }
|
|
666
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 1) {
|
|
667
|
+
return { ok: false, error: `${field} must be a positive integer` }
|
|
668
|
+
}
|
|
669
|
+
return { ok: true, value }
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function parseAgentLimitsFields(
|
|
673
|
+
body: Record<string, unknown>,
|
|
674
|
+
): { ok: true; value: Pick<AgentEditableFields, 'maxTurns' | 'contextBudgetTokens' | 'skillIndexBudgetTokens'> } | { ok: false; error: string } {
|
|
675
|
+
const value: Pick<AgentEditableFields, 'maxTurns' | 'contextBudgetTokens' | 'skillIndexBudgetTokens'> = {}
|
|
676
|
+
for (const field of ['maxTurns', 'contextBudgetTokens', 'skillIndexBudgetTokens'] as const) {
|
|
677
|
+
const parsed = parsePositiveInt(body[field], field)
|
|
678
|
+
if (!parsed.ok) return parsed
|
|
679
|
+
if (parsed.value !== undefined) value[field] = parsed.value
|
|
680
|
+
}
|
|
681
|
+
return { ok: true, value }
|
|
682
|
+
}
|
|
683
|
+
|
|
647
684
|
async function handleCreateAgent(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
648
685
|
const body = await readJsonBody(req)
|
|
649
686
|
if (typeof body.name !== 'string' || !body.name) {
|
|
@@ -677,9 +714,11 @@ async function handleCreateAgent(req: IncomingMessage, res: ServerResponse): Pro
|
|
|
677
714
|
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ path: indexPath, registered: true }))
|
|
678
715
|
}
|
|
679
716
|
|
|
680
|
-
// Backs the Overview tab's System prompt / Model
|
|
681
|
-
//
|
|
682
|
-
//
|
|
717
|
+
// Backs the Overview tab's System prompt / Model / Limits & budgets edit
|
|
718
|
+
// forms — any subset of systemPrompt, model, maxTurns, contextBudgetTokens,
|
|
719
|
+
// skillIndexBudgetTokens can be sent independently (see
|
|
720
|
+
// parseAgentTemplateOptions/parseAgentLimitsFields), every other field
|
|
721
|
+
// stays untouched both on disk and live. Persists via
|
|
683
722
|
// agent-file-admin.ts's editAgentFile (see its own doc comment for why
|
|
684
723
|
// this is a surgical AST edit, not a full file regeneration), then
|
|
685
724
|
// applies the exact same resolved values to *this* running process via
|
|
@@ -699,14 +738,22 @@ async function handleEditAgent(req: IncomingMessage, res: ServerResponse, agentN
|
|
|
699
738
|
res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: parsedOptions.error }))
|
|
700
739
|
return
|
|
701
740
|
}
|
|
702
|
-
|
|
703
|
-
|
|
741
|
+
const parsedLimits = parseAgentLimitsFields(body)
|
|
742
|
+
if (!parsedLimits.ok) {
|
|
743
|
+
res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: parsedLimits.error }))
|
|
744
|
+
return
|
|
745
|
+
}
|
|
746
|
+
const editFields: AgentEditableFields = { ...parsedOptions.value, ...parsedLimits.value }
|
|
747
|
+
if (Object.keys(editFields).length === 0) {
|
|
748
|
+
res
|
|
749
|
+
.writeHead(400, { 'content-type': 'application/json' })
|
|
750
|
+
.end(JSON.stringify({ error: 'systemPrompt, model, maxTurns, contextBudgetTokens, or skillIndexBudgetTokens is required' }))
|
|
704
751
|
return
|
|
705
752
|
}
|
|
706
753
|
|
|
707
754
|
let result: AgentEditResult
|
|
708
755
|
try {
|
|
709
|
-
result = editAgentFile(agentName,
|
|
756
|
+
result = editAgentFile(agentName, editFields)
|
|
710
757
|
} catch (err) {
|
|
711
758
|
const status =
|
|
712
759
|
err instanceof AgentEditNotSupportedError || err instanceof AgentModelError ? 400 : err instanceof AgentFileNotFoundError ? 404 : 500
|
|
@@ -716,6 +763,9 @@ async function handleEditAgent(req: IncomingMessage, res: ServerResponse, agentN
|
|
|
716
763
|
|
|
717
764
|
const configPatch: Partial<AgentConfig> = {}
|
|
718
765
|
if (result.systemPrompt !== undefined) configPatch.systemPrompt = result.systemPrompt
|
|
766
|
+
if (result.maxTurns !== undefined) configPatch.maxTurns = result.maxTurns
|
|
767
|
+
if (result.contextBudgetTokens !== undefined) configPatch.contextBudgetTokens = result.contextBudgetTokens
|
|
768
|
+
if (result.skillIndexBudgetTokens !== undefined) configPatch.skillIndexBudgetTokens = result.skillIndexBudgetTokens
|
|
719
769
|
let createModelCall: RegistryEntry['createModelCall'] | undefined
|
|
720
770
|
if (result.model) {
|
|
721
771
|
configPatch.model = result.model
|
|
@@ -782,6 +832,69 @@ function handleSkillDelete(res: ServerResponse, agentName: string, skillId: stri
|
|
|
782
832
|
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true }))
|
|
783
833
|
}
|
|
784
834
|
|
|
835
|
+
// Backs the Skills tab's "Browse skillgarden catalog" section — a
|
|
836
|
+
// read-only, agent-independent list of everything skillgarden ships in
|
|
837
|
+
// its own bundled registry (see loopengine's own web/skillgarden-admin.ts).
|
|
838
|
+
// Not wrapped in a per-agent :name segment since the catalog itself
|
|
839
|
+
// isn't scoped to any one agent; only the POST below (actually
|
|
840
|
+
// installing an entry) is.
|
|
841
|
+
function handleSkillgardenCatalogGet(res: ServerResponse): void {
|
|
842
|
+
try {
|
|
843
|
+
// Computed before writeHead, not inlined into .end()'s own argument —
|
|
844
|
+
// res.writeHead(200, ...) commits the status line the instant it's
|
|
845
|
+
// called, so a throw evaluated *after* it (as it would be if it were
|
|
846
|
+
// inlined as .end()'s argument) leaves headers already sent as 200
|
|
847
|
+
// with no body, and the catch block's own writeHead(status, ...)
|
|
848
|
+
// then fails outright (ERR_HTTP_HEADERS_SENT) instead of ever
|
|
849
|
+
// reaching the client.
|
|
850
|
+
const catalog = listSkillgardenCatalog()
|
|
851
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(catalog))
|
|
852
|
+
} catch (err) {
|
|
853
|
+
const status = err instanceof SkillgardenUnavailableError ? 503 : 500
|
|
854
|
+
res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// Full SKILL.md body for the catalog's own "View" toggle — kept as a
|
|
859
|
+
// separate route from the list above (rather than inlining every body
|
|
860
|
+
// into it) for the same reason the Skills tab's own edit form fetches a
|
|
861
|
+
// skill's body lazily instead of shipping it in the initial list.
|
|
862
|
+
function handleSkillgardenCatalogEntryGet(res: ServerResponse, category: string, id: string): void {
|
|
863
|
+
try {
|
|
864
|
+
// See handleSkillgardenCatalogGet's own comment for why this is
|
|
865
|
+
// computed before writeHead rather than inlined into .end()'s
|
|
866
|
+
// argument.
|
|
867
|
+
const entry = readSkillgardenCatalogEntry(category, id)
|
|
868
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(entry))
|
|
869
|
+
} catch (err) {
|
|
870
|
+
const status = err instanceof SkillgardenUnavailableError ? 503 : 404
|
|
871
|
+
res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// Installs a catalog entry into agents/:name/skills/ — delegates to
|
|
876
|
+
// skillgarden's own addSkill (see skillgarden-admin.ts), which throws
|
|
877
|
+
// plain Errors (no typed classes) for "unknown skill"/"already exists",
|
|
878
|
+
// so those two are told apart by message shape rather than instanceof,
|
|
879
|
+
// same as any other dependency that doesn't export typed errors for its
|
|
880
|
+
// own failure modes.
|
|
881
|
+
async function handleSkillgardenCatalogAdd(req: IncomingMessage, res: ServerResponse, agentName: string, category: string, id: string): Promise<void> {
|
|
882
|
+
if (!getEntry(agentName)) {
|
|
883
|
+
res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
|
|
884
|
+
return
|
|
885
|
+
}
|
|
886
|
+
const body = await readJsonBody(req)
|
|
887
|
+
const force = body.force === true
|
|
888
|
+
try {
|
|
889
|
+
const result = addSkillgardenSkillToAgent(agentName, category, id, force)
|
|
890
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true, id: result.id }))
|
|
891
|
+
} catch (err) {
|
|
892
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
893
|
+
const status = err instanceof SkillgardenUnavailableError ? 503 : /already exists/.test(message) ? 409 : /^Unknown skill/.test(message) ? 404 : 500
|
|
894
|
+
res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: message }))
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
785
898
|
// Backs the Actauth tab's rule editor — parses a rule body shared by both
|
|
786
899
|
// the add (POST) and update (PUT, minus `name`) routes below.
|
|
787
900
|
function parseActauthRuleBody(body: Record<string, unknown>, requireName: boolean): { ok: true; value: { name: string; scope: string; tool: string; decision: Decision } } | { ok: false; error: string } {
|
|
@@ -1906,6 +2019,23 @@ const server = createServer(async (req, res) => {
|
|
|
1906
2019
|
return
|
|
1907
2020
|
}
|
|
1908
2021
|
|
|
2022
|
+
// Backs the Skills tab's "Browse skillgarden catalog" section — see
|
|
2023
|
+
// the three handlers above it.
|
|
2024
|
+
if (req.method === 'GET' && pathname === '/skillgarden-catalog') {
|
|
2025
|
+
handleSkillgardenCatalogGet(res)
|
|
2026
|
+
return
|
|
2027
|
+
}
|
|
2028
|
+
const skillgardenEntryMatch = req.method === 'GET' && pathname.match(/^\/skillgarden-catalog\/([^/]+)\/([^/]+)$/)
|
|
2029
|
+
if (skillgardenEntryMatch) {
|
|
2030
|
+
handleSkillgardenCatalogEntryGet(res, decodeURIComponent(skillgardenEntryMatch[1]), decodeURIComponent(skillgardenEntryMatch[2]))
|
|
2031
|
+
return
|
|
2032
|
+
}
|
|
2033
|
+
const skillgardenAddMatch = req.method === 'POST' && pathname.match(/^\/agents\/([^/]+)\/skillgarden-catalog\/([^/]+)\/([^/]+)$/)
|
|
2034
|
+
if (skillgardenAddMatch) {
|
|
2035
|
+
await handleSkillgardenCatalogAdd(req, res, decodeURIComponent(skillgardenAddMatch[1]), decodeURIComponent(skillgardenAddMatch[2]), decodeURIComponent(skillgardenAddMatch[3]))
|
|
2036
|
+
return
|
|
2037
|
+
}
|
|
2038
|
+
|
|
1909
2039
|
// Backs the Actauth tab's rule editor and default_decision control.
|
|
1910
2040
|
// The default-decision route is checked before the :ruleName routes
|
|
1911
2041
|
// right below since 'default-decision' would otherwise itself match
|
package/template/package.json
CHANGED