loopengine 0.1.5 → 0.1.6
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/PROTOCOL.md +1 -1
- package/dist/core/run-agent.js +17 -3
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/web/agent-file-admin.d.ts +6 -0
- package/dist/web/agent-file-admin.js +50 -0
- package/dist/web/agents-config-page.js +329 -12
- package/dist/web/skillgarden-admin.d.ts +24 -0
- package/dist/web/skillgarden-admin.js +76 -0
- package/package.json +2 -2
package/PROTOCOL.md
CHANGED
|
@@ -190,7 +190,7 @@ loopengine run customer-service --json "order A-1001 arrived broken, can you ref
|
|
|
190
190
|
|
|
191
191
|
```
|
|
192
192
|
{"type":"session","sessionId":"5a83c6b4-..."}
|
|
193
|
-
{"type":"budget:check","action":"ok","usedTokens":2,"budgetTokens":
|
|
193
|
+
{"type":"budget:check","action":"ok","usedTokens":2,"budgetTokens":100000,"ratio":0.00002}
|
|
194
194
|
{"type":"tool:started","id":"call_1","tool":"issue_refund", ...}
|
|
195
195
|
{"type":"done","text":"Refunded $42.00..."}
|
|
196
196
|
```
|
package/dist/core/run-agent.js
CHANGED
|
@@ -310,7 +310,12 @@ async function buildTurnContext(config, modelCall, options) {
|
|
|
310
310
|
// that doesn't exist (a flat-file agent with no skills at all)
|
|
311
311
|
// harmless: an empty index, not an error.
|
|
312
312
|
const skillsDirs = config.skillsDirs ?? [`agents/${config.name}/skills`];
|
|
313
|
-
|
|
313
|
+
// 2000, not an arbitrary round number: skillgarden's own default is
|
|
314
|
+
// ~1% of a real agent context window (~100k-200k tokens for the
|
|
315
|
+
// providers this repo wires up) — see its own SkillGardenOptions doc
|
|
316
|
+
// comment. loopengine used to default lower (200), which left barely
|
|
317
|
+
// enough room for a handful of skills before truncating the index.
|
|
318
|
+
const skillGarden = new SkillGarden({ dirs: [...skillsDirs, systemSkillsDir], indexBudgetTokens: config.skillIndexBudgetTokens ?? 2000 });
|
|
314
319
|
const skillIndex = skillGarden.buildIndex().included;
|
|
315
320
|
// Also the tail-preservation window recover() below relies on — kept as
|
|
316
321
|
// one named constant so the two can never drift out of sync.
|
|
@@ -321,8 +326,17 @@ async function buildTurnContext(config, modelCall, options) {
|
|
|
321
326
|
// budgetTokens/softThreshold so a nudge firing and compaction's own
|
|
322
327
|
// recovery target agree — see budget.ts's own BudgetTrackerOptions doc
|
|
323
328
|
// comment.
|
|
324
|
-
|
|
325
|
-
|
|
329
|
+
// 100000, not 8000: the providers this repo wires up (Claude, GPT-4o,
|
|
330
|
+
// DeepSeek) all give a real conversation 128k-200k tokens of window,
|
|
331
|
+
// and 8000 was small enough that a handful of tool calls (each with a
|
|
332
|
+
// full JSON schema + result) routinely tripped compaction on an
|
|
333
|
+
// otherwise ordinary turn. 100000 leaves headroom under even the
|
|
334
|
+
// smallest of those windows for the system prompt, tool schemas, and
|
|
335
|
+
// the model's own response, while softThreshold's default 0.7 (a
|
|
336
|
+
// 70000-token nudge point) and hardThreshold's 0.92 still leave real
|
|
337
|
+
// margin before hitting it.
|
|
338
|
+
const budgetTracker = new BudgetTracker({ budgetTokens: config.contextBudgetTokens ?? 100000 });
|
|
339
|
+
const compactor = new Compactor({ budgetTokens: config.contextBudgetTokens ?? 100000, softThreshold: budgetTracker.softThreshold, tailMessages });
|
|
326
340
|
const rules = loadRules(config);
|
|
327
341
|
// AgentConfig.httpNotifier only ever stands in for the http channel —
|
|
328
342
|
// see its own doc comment for why cli/http_stream never consult it at
|
package/dist/index.d.ts
CHANGED
|
@@ -35,6 +35,8 @@ export { globalConfigPageHtml } from './web/global-config-page.js';
|
|
|
35
35
|
export { playgroundHtml } from './web/playground.js';
|
|
36
36
|
export { readSkill, writeSkill, deleteSkill, SkillInvalidIdError, SkillNotFoundError } from './web/skills-admin.js';
|
|
37
37
|
export type { SkillContent } from './web/skills-admin.js';
|
|
38
|
+
export { listSkillgardenCatalog, readSkillgardenCatalogEntry, addSkillgardenSkillToAgent, SkillgardenUnavailableError } from './web/skillgarden-admin.js';
|
|
39
|
+
export type { SkillgardenCatalogEntry, SkillgardenCatalogDetail } from './web/skillgarden-admin.js';
|
|
38
40
|
export { readActauthConfig, addActauthRule, updateActauthRule, removeActauthRule, setDefaultDecision, ActauthRuleExistsError, ActauthRuleNotFoundError, } from './web/actauth-admin.js';
|
|
39
41
|
export type { ActauthRuleInput, ActauthConfigView } from './web/actauth-admin.js';
|
|
40
42
|
export { describeModelProviders, describeGateways } from './web/global-config.js';
|
package/dist/index.js
CHANGED
|
@@ -40,6 +40,7 @@ export { agentsListPageHtml } from './web/agents-list-page.js';
|
|
|
40
40
|
export { globalConfigPageHtml } from './web/global-config-page.js';
|
|
41
41
|
export { playgroundHtml } from './web/playground.js';
|
|
42
42
|
export { readSkill, writeSkill, deleteSkill, SkillInvalidIdError, SkillNotFoundError } from './web/skills-admin.js';
|
|
43
|
+
export { listSkillgardenCatalog, readSkillgardenCatalogEntry, addSkillgardenSkillToAgent, SkillgardenUnavailableError } from './web/skillgarden-admin.js';
|
|
43
44
|
export { readActauthConfig, addActauthRule, updateActauthRule, removeActauthRule, setDefaultDecision, ActauthRuleExistsError, ActauthRuleNotFoundError, } from './web/actauth-admin.js';
|
|
44
45
|
export { describeModelProviders, describeGateways } from './web/global-config.js';
|
|
45
46
|
export { createTrackedApprover, listApprovals, decideApproval, findApproval } from './web/web-approver.js';
|
|
@@ -9,6 +9,9 @@ export interface AgentEditableFields {
|
|
|
9
9
|
provider: Provider;
|
|
10
10
|
model?: string;
|
|
11
11
|
};
|
|
12
|
+
maxTurns?: number;
|
|
13
|
+
contextBudgetTokens?: number;
|
|
14
|
+
skillIndexBudgetTokens?: number;
|
|
12
15
|
}
|
|
13
16
|
export interface AgentEditResult {
|
|
14
17
|
systemPrompt?: string;
|
|
@@ -16,6 +19,9 @@ export interface AgentEditResult {
|
|
|
16
19
|
provider: Provider;
|
|
17
20
|
model: string;
|
|
18
21
|
};
|
|
22
|
+
maxTurns?: number;
|
|
23
|
+
contextBudgetTokens?: number;
|
|
24
|
+
skillIndexBudgetTokens?: number;
|
|
19
25
|
}
|
|
20
26
|
/** Surgically rewrites just the fields given in `fields` — see this
|
|
21
27
|
* file's own header comment for why, and for the "refuse rather than
|
|
@@ -89,6 +89,31 @@ function findConfigObjectLiteral(sourceFile) {
|
|
|
89
89
|
function findProperty(obj, name) {
|
|
90
90
|
return obj.properties.find((p) => p.name !== undefined && ts.isIdentifier(p.name) && p.name.text === name);
|
|
91
91
|
}
|
|
92
|
+
// maxTurns/contextBudgetTokens/skillIndexBudgetTokens are the only three
|
|
93
|
+
// AgentConfig fields this file edits that are *usually absent* — every
|
|
94
|
+
// agent in this repo, and create-loopengine's own scaffold template,
|
|
95
|
+
// leaves all three unset and rides run-agent.ts's own defaults (25 /
|
|
96
|
+
// 100000 / 2000), unlike systemPrompt/model which agentIndexTemplate always
|
|
97
|
+
// writes out explicitly. So "the property isn't in this file" can't mean
|
|
98
|
+
// refuse here the way it does for a genuinely unusual systemPrompt/model
|
|
99
|
+
// shape — that would make every freshly scaffolded agent's limits
|
|
100
|
+
// permanently uneditable through this API. Missing properties are
|
|
101
|
+
// collected into `toInsert` instead and spliced in as new lines just
|
|
102
|
+
// before the object literal's closing brace (see editAgentFile's own use
|
|
103
|
+
// of this); an *existing* property whose value isn't a plain number
|
|
104
|
+
// literal is still refused, same "don't guess" rule as everything else
|
|
105
|
+
// in this file.
|
|
106
|
+
function upsertNumericProperty(configObj, sourceFile, edits, toInsert, name, value) {
|
|
107
|
+
const prop = findProperty(configObj, name);
|
|
108
|
+
if (!prop) {
|
|
109
|
+
toInsert.push(`${name}: ${value}`);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (!ts.isPropertyAssignment(prop) || !ts.isNumericLiteral(prop.initializer)) {
|
|
113
|
+
throw new AgentEditNotSupportedError(`${name} in this file is not a plain number literal — edit it directly.`);
|
|
114
|
+
}
|
|
115
|
+
edits.push({ start: prop.initializer.getStart(sourceFile), end: prop.initializer.getEnd(), text: String(value) });
|
|
116
|
+
}
|
|
92
117
|
/** Surgically rewrites just the fields given in `fields` — see this
|
|
93
118
|
* file's own header comment for why, and for the "refuse rather than
|
|
94
119
|
* guess" rule this follows for anything that isn't a plain, recognizable
|
|
@@ -150,6 +175,31 @@ export function editAgentFile(agentName, fields) {
|
|
|
150
175
|
edits.push({ start: comment.pos, end: comment.end, text: `// reads ${MODEL_ENV_VAR[provider]}` });
|
|
151
176
|
}
|
|
152
177
|
}
|
|
178
|
+
const toInsert = [];
|
|
179
|
+
for (const name of ['maxTurns', 'contextBudgetTokens', 'skillIndexBudgetTokens']) {
|
|
180
|
+
const value = fields[name];
|
|
181
|
+
if (value === undefined)
|
|
182
|
+
continue;
|
|
183
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
184
|
+
throw new AgentEditNotSupportedError(`${name} must be a positive integer.`);
|
|
185
|
+
}
|
|
186
|
+
upsertNumericProperty(configObj, sourceFile, edits, toInsert, name, value);
|
|
187
|
+
result[name] = value;
|
|
188
|
+
}
|
|
189
|
+
if (toInsert.length) {
|
|
190
|
+
// Insert right before the closing "}", not right after the last
|
|
191
|
+
// property node — a hand-written trailing line comment there (like
|
|
192
|
+
// model's own "// reads ANTHROPIC_API_KEY" above) would otherwise end
|
|
193
|
+
// up with new properties spliced into the middle of it. The object's
|
|
194
|
+
// own pre-existing newline-before-"}" becomes the separator before
|
|
195
|
+
// the first inserted line for free; only an object with *no*
|
|
196
|
+
// properties at all needs its own leading newline.
|
|
197
|
+
const closeBracePos = configObj.getEnd() - 1;
|
|
198
|
+
const hasProps = configObj.properties.length > 0;
|
|
199
|
+
const needsLeadingComma = hasProps && !configObj.properties.hasTrailingComma;
|
|
200
|
+
const text = (hasProps ? '' : '\n') + (needsLeadingComma ? ',' : '') + toInsert.map((p) => ` ${p},\n`).join('');
|
|
201
|
+
edits.push({ start: closeBracePos, end: closeBracePos, text });
|
|
202
|
+
}
|
|
153
203
|
if (!edits.length)
|
|
154
204
|
return result;
|
|
155
205
|
// Apply from the end of the file backward — each edit's start/end was
|
|
@@ -131,12 +131,12 @@ export const agentsConfigPageHtml = `<!doctype html>
|
|
|
131
131
|
letter-spacing: normal;
|
|
132
132
|
padding: 2px 8px;
|
|
133
133
|
}
|
|
134
|
-
.
|
|
134
|
+
.admin-subsection {
|
|
135
135
|
margin-top: 14px;
|
|
136
136
|
padding-top: 14px;
|
|
137
137
|
border-top: 1px dashed light-dark(#eee, #2a2a2e);
|
|
138
138
|
}
|
|
139
|
-
.
|
|
139
|
+
.admin-subsection h4 {
|
|
140
140
|
font-size: 12px;
|
|
141
141
|
text-transform: uppercase;
|
|
142
142
|
letter-spacing: 0.04em;
|
|
@@ -309,6 +309,84 @@ export const agentsConfigPageHtml = `<!doctype html>
|
|
|
309
309
|
stretches the checkbox to the full row width, which visually
|
|
310
310
|
overlaps the description text under it instead of sitting beside it. */
|
|
311
311
|
form.add-source input[type="checkbox"] { width: auto; flex-shrink: 0; margin-top: 2px; }
|
|
312
|
+
/* Same column-stacking issue as .tool-picker-item above: .http-tool-
|
|
313
|
+
inline-label (the "required" / "send as JSON body" checkboxes in the
|
|
314
|
+
HTTP tool builder) is a <label> too, so "form.add-source label" wins
|
|
315
|
+
on specificity alone and stacks the checkbox above its own text
|
|
316
|
+
instead of beside it — this rule needs to match "label" too to win. */
|
|
317
|
+
form.add-source label.http-tool-inline-label {
|
|
318
|
+
display: flex;
|
|
319
|
+
flex-direction: row;
|
|
320
|
+
align-items: center;
|
|
321
|
+
gap: 6px;
|
|
322
|
+
}
|
|
323
|
+
/* These three are plain <div>s of sibling inputs/selects/buttons, not
|
|
324
|
+
<label>s, so none of the rules above touch them — without an explicit
|
|
325
|
+
row layout they just wrap at the browser's default inline spacing,
|
|
326
|
+
which reads as everything jammed together with no breathing room.
|
|
327
|
+
form.add-source itself caps at max-width: 480px (see above), which
|
|
328
|
+
isn't wide enough to hold a field row's 5 items (name/type/
|
|
329
|
+
description/required/delete) on one line at a readable width, so
|
|
330
|
+
this wraps rather than forcing a single cramped line — each item
|
|
331
|
+
gets a min-width so text inputs wrap onto their own line instead of
|
|
332
|
+
shrinking to an unreadable sliver. */
|
|
333
|
+
.http-tool-field-row, .http-tool-header-row, .http-tool-method-url {
|
|
334
|
+
display: flex;
|
|
335
|
+
flex-wrap: wrap;
|
|
336
|
+
align-items: center;
|
|
337
|
+
gap: 8px;
|
|
338
|
+
margin-bottom: 8px;
|
|
339
|
+
}
|
|
340
|
+
/* Same "form.add-source input { width: 100% }" override as the checkbox
|
|
341
|
+
fix above — each field here sits beside its siblings in a row, not
|
|
342
|
+
stacked full-width above them. */
|
|
343
|
+
.http-tool-field-row input[type="text"],
|
|
344
|
+
.http-tool-field-row select,
|
|
345
|
+
.http-tool-header-row input[type="text"],
|
|
346
|
+
.http-tool-method-url input[type="text"],
|
|
347
|
+
.http-tool-method-url select {
|
|
348
|
+
width: auto;
|
|
349
|
+
}
|
|
350
|
+
.http-tool-field-row .http-tool-field-name { flex: 1 1 100px; min-width: 100px; }
|
|
351
|
+
.http-tool-field-row .http-tool-field-type { flex: 0 0 auto; }
|
|
352
|
+
.http-tool-field-row .http-tool-field-description { flex: 2 1 140px; min-width: 140px; }
|
|
353
|
+
.http-tool-field-row .http-tool-inline-label { flex: 0 0 auto; white-space: nowrap; }
|
|
354
|
+
.http-tool-header-row .http-tool-header-key { flex: 1 1 100px; min-width: 100px; }
|
|
355
|
+
.http-tool-header-row .http-tool-header-value { flex: 2 1 140px; min-width: 140px; }
|
|
356
|
+
.http-tool-method-url select { flex: 0 0 auto; }
|
|
357
|
+
.http-tool-method-url input[type="text"] { flex: 1 1 160px; min-width: 160px; }
|
|
358
|
+
.http-tool-field-row .http-tool-remove-row,
|
|
359
|
+
.http-tool-header-row .http-tool-remove-row {
|
|
360
|
+
flex: 0 0 auto;
|
|
361
|
+
margin-left: auto;
|
|
362
|
+
}
|
|
363
|
+
/* Taller than .hint-btn's own 2px 8px default (shared with the Gateway
|
|
364
|
+
Tools "Refresh" button, which sits inline in an <h3> and shouldn't
|
|
365
|
+
grow) — these two sit on their own line below the field/header rows,
|
|
366
|
+
so there's no header-row height to match. */
|
|
367
|
+
#httpToolAddFieldBtn, #httpToolAddHeaderBtn {
|
|
368
|
+
padding: 6px 12px;
|
|
369
|
+
}
|
|
370
|
+
.skillgarden-category-badge {
|
|
371
|
+
display: inline-block;
|
|
372
|
+
font-family: ui-sans-serif, system-ui, sans-serif;
|
|
373
|
+
font-size: 10px;
|
|
374
|
+
font-weight: 600;
|
|
375
|
+
text-transform: uppercase;
|
|
376
|
+
letter-spacing: 0.03em;
|
|
377
|
+
padding: 1px 7px;
|
|
378
|
+
border-radius: 999px;
|
|
379
|
+
background: light-dark(#eef1ec, #1e2519);
|
|
380
|
+
color: light-dark(#3f6b4a, #7fbf8f);
|
|
381
|
+
margin-right: 6px;
|
|
382
|
+
}
|
|
383
|
+
#skillgardenFilterInput { margin-bottom: 8px; }
|
|
384
|
+
.skillgarden-catalog-body {
|
|
385
|
+
margin-top: 8px;
|
|
386
|
+
padding-top: 8px;
|
|
387
|
+
border-top: 1px solid light-dark(#eee, #2a2a2e);
|
|
388
|
+
font-size: 13px;
|
|
389
|
+
}
|
|
312
390
|
</style>
|
|
313
391
|
</head>
|
|
314
392
|
<body>
|
|
@@ -599,15 +677,31 @@ export const agentsConfigPageHtml = `<!doctype html>
|
|
|
599
677
|
'<dt>tenantFor</dt><dd>' + badge(cfg.tenantFor.indexOf('custom') === 0 ? 'custom' : 'default') + ' <span class="muted">' + escapeHtml(cfg.tenantFor) + '</span></dd>' +
|
|
600
678
|
'<dt>isSafeTool</dt><dd>' + badge(cfg.isSafeTool === 'custom' ? 'custom' : 'default') + ' <span class="muted">' + escapeHtml(cfg.isSafeTool) + '</span></dd>' +
|
|
601
679
|
'</dl></section>' +
|
|
602
|
-
'<section><h3>Limits & budgets
|
|
603
|
-
'<
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
'</dl
|
|
680
|
+
'<section><h3>Limits & budgets <button type="button" class="edit-btn" id="editLimitsBtn">Edit</button></h3>' +
|
|
681
|
+
'<dl class="kv" id="limitsDisplay">' +
|
|
682
|
+
'<dt>maxTurns</dt><dd>' + escapeHtml(cfg.maxTurns) + '</dd>' +
|
|
683
|
+
'<dt>contextBudgetTokens</dt><dd>' + escapeHtml(cfg.contextBudgetTokens) + '</dd>' +
|
|
684
|
+
'<dt>skillIndexBudgetTokens</dt><dd>' + escapeHtml(cfg.skillIndexBudgetTokens) + '</dd>' +
|
|
685
|
+
'</dl>' +
|
|
686
|
+
'<form class="add-source" id="limitsForm" style="display:none">' +
|
|
687
|
+
'<label>Max turns <span class="hint">(hard cap on model calls in one turn)</span>' +
|
|
688
|
+
'<input type="number" name="maxTurns" min="1" step="1" value="' + escapeHtml(cfg.maxTurns) + '">' +
|
|
689
|
+
'</label>' +
|
|
690
|
+
'<label>Context budget <span class="hint">(tokens — triggers compaction of older messages once exceeded)</span>' +
|
|
691
|
+
'<input type="number" name="contextBudgetTokens" min="1" step="1" value="' + escapeHtml(cfg.contextBudgetTokens) + '">' +
|
|
692
|
+
'</label>' +
|
|
693
|
+
'<label>Skill index budget <span class="hint">(tokens — caps the always-loaded skill name/description index)</span>' +
|
|
694
|
+
'<input type="number" name="skillIndexBudgetTokens" min="1" step="1" value="' + escapeHtml(cfg.skillIndexBudgetTokens) + '">' +
|
|
695
|
+
'</label>' +
|
|
696
|
+
'<button type="submit">Save</button>' +
|
|
697
|
+
'<button type="button" id="cancelLimitsBtn">Cancel</button>' +
|
|
698
|
+
'<div class="error" id="limitsError"></div>' +
|
|
699
|
+
'</form>' +
|
|
700
|
+
'<dl class="kv"><dt>skillsDirs</dt><dd>' + escapeHtml(cfg.skillsDirs.join(', ') || '(none)') + '</dd></dl>' +
|
|
701
|
+
'</section>';
|
|
608
702
|
}
|
|
609
703
|
|
|
610
|
-
// Wires the System prompt / Model Edit buttons rendered by
|
|
704
|
+
// Wires the System prompt / Model / Limits & budgets Edit buttons rendered by
|
|
611
705
|
// renderOverviewHtml above — called every time that HTML gets (re)set
|
|
612
706
|
// (renderDetail, refreshOverviewPanel, refreshSkillsDependentPanels),
|
|
613
707
|
// same pattern every other tab's own wireXHandlers already follows.
|
|
@@ -708,6 +802,53 @@ export const agentsConfigPageHtml = `<!doctype html>
|
|
|
708
802
|
});
|
|
709
803
|
});
|
|
710
804
|
}
|
|
805
|
+
|
|
806
|
+
var editLimitsBtn = overviewPanel.querySelector('#editLimitsBtn');
|
|
807
|
+
if (editLimitsBtn) {
|
|
808
|
+
var limitsDisplay = overviewPanel.querySelector('#limitsDisplay');
|
|
809
|
+
var limitsForm = overviewPanel.querySelector('#limitsForm');
|
|
810
|
+
var limitsError = overviewPanel.querySelector('#limitsError');
|
|
811
|
+
|
|
812
|
+
editLimitsBtn.addEventListener('click', function () {
|
|
813
|
+
limitsDisplay.style.display = 'none';
|
|
814
|
+
editLimitsBtn.style.display = 'none';
|
|
815
|
+
limitsForm.style.display = '';
|
|
816
|
+
});
|
|
817
|
+
overviewPanel.querySelector('#cancelLimitsBtn').addEventListener('click', function () {
|
|
818
|
+
limitsForm.style.display = 'none';
|
|
819
|
+
limitsDisplay.style.display = '';
|
|
820
|
+
editLimitsBtn.style.display = '';
|
|
821
|
+
limitsError.textContent = '';
|
|
822
|
+
});
|
|
823
|
+
limitsForm.addEventListener('submit', function (ev) {
|
|
824
|
+
ev.preventDefault();
|
|
825
|
+
limitsError.textContent = '';
|
|
826
|
+
var data = new FormData(limitsForm);
|
|
827
|
+
var submitBtn = limitsForm.querySelector('button[type="submit"]');
|
|
828
|
+
submitBtn.disabled = true;
|
|
829
|
+
var body = {
|
|
830
|
+
maxTurns: Number(data.get('maxTurns')),
|
|
831
|
+
contextBudgetTokens: Number(data.get('contextBudgetTokens')),
|
|
832
|
+
skillIndexBudgetTokens: Number(data.get('skillIndexBudgetTokens')),
|
|
833
|
+
};
|
|
834
|
+
fetch('/agents/' + encodeURIComponent(name), {
|
|
835
|
+
method: 'PUT',
|
|
836
|
+
headers: { 'content-type': 'application/json' },
|
|
837
|
+
body: JSON.stringify(body),
|
|
838
|
+
})
|
|
839
|
+
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
|
|
840
|
+
.then(function (result) {
|
|
841
|
+
if (!result.ok) throw new Error(result.body.error || 'request failed');
|
|
842
|
+
currentCfg = result.body;
|
|
843
|
+
overviewPanel.innerHTML = renderOverviewHtml(result.body);
|
|
844
|
+
wireOverviewHandlers(name);
|
|
845
|
+
})
|
|
846
|
+
.catch(function (err) {
|
|
847
|
+
limitsError.textContent = err.message;
|
|
848
|
+
submitBtn.disabled = false;
|
|
849
|
+
});
|
|
850
|
+
});
|
|
851
|
+
}
|
|
711
852
|
}
|
|
712
853
|
|
|
713
854
|
// ---- Overview's own Actauth section: read-only, from cfg.permissions
|
|
@@ -1016,18 +1157,194 @@ export const agentsConfigPageHtml = `<!doctype html>
|
|
|
1016
1157
|
'<button type="submit" id="skillSubmitBtn">Add</button>' +
|
|
1017
1158
|
'<button type="button" id="skillCancelBtn" style="display:none">Cancel</button>' +
|
|
1018
1159
|
'<div id="skillFormError" class="error"></div>' +
|
|
1019
|
-
'</form></section>'
|
|
1160
|
+
'</form></section>' +
|
|
1161
|
+
'<section><h3>Browse skillgarden catalog</h3>' +
|
|
1162
|
+
'<input type="text" id="skillgardenFilterInput" placeholder="Filter by name or category…">' +
|
|
1163
|
+
'<div id="skillgardenCatalogList"><p class="hint">Loading…</p></div>' +
|
|
1164
|
+
'</section>';
|
|
1020
1165
|
}
|
|
1021
1166
|
|
|
1022
1167
|
function skillsPanelEl() {
|
|
1023
1168
|
return detail.querySelector('[data-tab-panel="skills"]');
|
|
1024
1169
|
}
|
|
1025
1170
|
|
|
1171
|
+
// ---- Skills tab's "Browse skillgarden catalog" section: a
|
|
1172
|
+
// read-only, agent-independent list of everything GET /skillgarden-
|
|
1173
|
+
// catalog reports (see web/skillgarden-admin.ts) — cached in this one
|
|
1174
|
+
// module-level variable, not per-agent like gatewayLoadedFor, since
|
|
1175
|
+
// the catalog itself never varies by agent. renderSkillsTabHtml
|
|
1176
|
+
// regenerates the whole Skills panel (including this section's own
|
|
1177
|
+
// markup) on every agent switch/refresh, so loadSkillgardenCatalogSection
|
|
1178
|
+
// re-renders from this cache instead of re-fetching every time — only
|
|
1179
|
+
// the very first time this tab is opened in the page's lifetime hits
|
|
1180
|
+
// the network at all. ----
|
|
1181
|
+
var skillgardenCatalog = null;
|
|
1182
|
+
|
|
1183
|
+
function skillgardenCatalogListEl() {
|
|
1184
|
+
var panel = skillsPanelEl();
|
|
1185
|
+
return panel ? panel.querySelector('#skillgardenCatalogList') : null;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
function renderSkillgardenCatalogEntry(entry) {
|
|
1189
|
+
return '<div class="source" data-category="' + escapeHtml(entry.category) + '" data-id="' + escapeHtml(entry.id) + '">' +
|
|
1190
|
+
'<div class="source-head">' +
|
|
1191
|
+
'<h4><span class="skillgarden-category-badge">' + escapeHtml(entry.category) + '</span>' + escapeHtml(entry.id) + '</h4>' +
|
|
1192
|
+
'<span>' +
|
|
1193
|
+
'<button type="button" class="skillgarden-view-btn" data-category="' + escapeHtml(entry.category) + '" data-id="' + escapeHtml(entry.id) + '">View</button> ' +
|
|
1194
|
+
'<button type="button" class="skillgarden-add-btn" data-category="' + escapeHtml(entry.category) + '" data-id="' + escapeHtml(entry.id) + '">Add to this agent</button>' +
|
|
1195
|
+
'</span>' +
|
|
1196
|
+
'</div>' +
|
|
1197
|
+
'<p class="hint">' + escapeHtml(entry.description) + '</p>' +
|
|
1198
|
+
'<div class="skillgarden-catalog-body" style="display:none"></div>' +
|
|
1199
|
+
'</div>';
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
function renderSkillgardenCatalogList(entries) {
|
|
1203
|
+
return entries.length ? entries.map(renderSkillgardenCatalogEntry).join('') : '<p class="hint">No skills match.</p>';
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
function applySkillgardenCatalog(name, entries) {
|
|
1207
|
+
var listEl = skillgardenCatalogListEl();
|
|
1208
|
+
if (!listEl) return;
|
|
1209
|
+
listEl.innerHTML = renderSkillgardenCatalogList(entries);
|
|
1210
|
+
wireSkillgardenCatalogHandlers(name);
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
function filteredSkillgardenCatalog(filterText) {
|
|
1214
|
+
if (!filterText) return skillgardenCatalog;
|
|
1215
|
+
var needle = filterText.toLowerCase();
|
|
1216
|
+
return skillgardenCatalog.filter(function (entry) {
|
|
1217
|
+
return entry.category.toLowerCase().indexOf(needle) !== -1 || entry.id.toLowerCase().indexOf(needle) !== -1;
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
function loadSkillgardenCatalogSection(name) {
|
|
1222
|
+
var listEl = skillgardenCatalogListEl();
|
|
1223
|
+
if (!listEl) return;
|
|
1224
|
+
if (skillgardenCatalog) {
|
|
1225
|
+
var panel = skillsPanelEl();
|
|
1226
|
+
var filterInput = panel ? panel.querySelector('#skillgardenFilterInput') : null;
|
|
1227
|
+
applySkillgardenCatalog(name, filteredSkillgardenCatalog(filterInput ? filterInput.value.trim() : ''));
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1230
|
+
listEl.innerHTML = '<p class="hint">Loading…</p>';
|
|
1231
|
+
fetch('/skillgarden-catalog')
|
|
1232
|
+
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
|
|
1233
|
+
.then(function (result) {
|
|
1234
|
+
if (!result.ok) throw new Error(result.body.error || 'request failed');
|
|
1235
|
+
skillgardenCatalog = result.body;
|
|
1236
|
+
if (currentName !== name) return;
|
|
1237
|
+
applySkillgardenCatalog(name, skillgardenCatalog);
|
|
1238
|
+
})
|
|
1239
|
+
.catch(function (err) {
|
|
1240
|
+
if (currentName !== name) return;
|
|
1241
|
+
listEl.innerHTML = '<p class="error">Could not load skillgarden catalog: ' + escapeHtml(err.message) + '</p>';
|
|
1242
|
+
});
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
// force=true retries as an explicit overwrite after the operator
|
|
1246
|
+
// confirms — same "ask once a plain add collides, don't ask up front"
|
|
1247
|
+
// flow the rest of this page never has to think about since nothing
|
|
1248
|
+
// else lets an operator add something whose id they don't already
|
|
1249
|
+
// control the uniqueness of themselves.
|
|
1250
|
+
function performSkillgardenAdd(name, category, id, btn, originalText, force) {
|
|
1251
|
+
fetch('/agents/' + encodeURIComponent(name) + '/skillgarden-catalog/' + encodeURIComponent(category) + '/' + encodeURIComponent(id), {
|
|
1252
|
+
method: 'POST',
|
|
1253
|
+
headers: { 'content-type': 'application/json' },
|
|
1254
|
+
body: JSON.stringify({ force: !!force }),
|
|
1255
|
+
})
|
|
1256
|
+
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
|
|
1257
|
+
.then(function (result) {
|
|
1258
|
+
if (result.ok) {
|
|
1259
|
+
refreshSkillsDependentPanels(name);
|
|
1260
|
+
return;
|
|
1261
|
+
}
|
|
1262
|
+
if (!force && /already exists/.test(result.body.error || '')) {
|
|
1263
|
+
if (confirm('A skill named "' + id + '" already exists for this agent. Overwrite it?')) {
|
|
1264
|
+
performSkillgardenAdd(name, category, id, btn, originalText, true);
|
|
1265
|
+
return;
|
|
1266
|
+
}
|
|
1267
|
+
btn.disabled = false;
|
|
1268
|
+
btn.textContent = originalText;
|
|
1269
|
+
return;
|
|
1270
|
+
}
|
|
1271
|
+
throw new Error(result.body.error || 'request failed');
|
|
1272
|
+
})
|
|
1273
|
+
.catch(function (err) {
|
|
1274
|
+
alert('Could not add "' + id + '": ' + err.message);
|
|
1275
|
+
btn.disabled = false;
|
|
1276
|
+
btn.textContent = originalText;
|
|
1277
|
+
});
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
function wireSkillgardenCatalogHandlers(name) {
|
|
1281
|
+
var listEl = skillgardenCatalogListEl();
|
|
1282
|
+
if (!listEl) return;
|
|
1283
|
+
|
|
1284
|
+
var viewButtons = listEl.querySelectorAll('.skillgarden-view-btn');
|
|
1285
|
+
for (var i = 0; i < viewButtons.length; i++) {
|
|
1286
|
+
viewButtons[i].addEventListener('click', function (ev) {
|
|
1287
|
+
var btn = ev.currentTarget;
|
|
1288
|
+
var category = btn.getAttribute('data-category');
|
|
1289
|
+
var id = btn.getAttribute('data-id');
|
|
1290
|
+
var card = btn.closest('.source');
|
|
1291
|
+
var bodyEl = card.querySelector('.skillgarden-catalog-body');
|
|
1292
|
+
if (bodyEl.style.display !== 'none') {
|
|
1293
|
+
bodyEl.style.display = 'none';
|
|
1294
|
+
btn.textContent = 'View';
|
|
1295
|
+
return;
|
|
1296
|
+
}
|
|
1297
|
+
if (bodyEl.getAttribute('data-loaded') === '1') {
|
|
1298
|
+
bodyEl.style.display = 'block';
|
|
1299
|
+
btn.textContent = 'Hide';
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
btn.disabled = true;
|
|
1303
|
+
fetch('/skillgarden-catalog/' + encodeURIComponent(category) + '/' + encodeURIComponent(id))
|
|
1304
|
+
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
|
|
1305
|
+
.then(function (result) {
|
|
1306
|
+
if (!result.ok) throw new Error(result.body.error || 'request failed');
|
|
1307
|
+
bodyEl.innerHTML = renderMarkdownPreview(result.body.body);
|
|
1308
|
+
bodyEl.setAttribute('data-loaded', '1');
|
|
1309
|
+
bodyEl.style.display = 'block';
|
|
1310
|
+
btn.textContent = 'Hide';
|
|
1311
|
+
})
|
|
1312
|
+
.catch(function (err) {
|
|
1313
|
+
bodyEl.innerHTML = '<p class="error">' + escapeHtml(err.message) + '</p>';
|
|
1314
|
+
bodyEl.style.display = 'block';
|
|
1315
|
+
})
|
|
1316
|
+
.then(function () { btn.disabled = false; });
|
|
1317
|
+
});
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
var addButtons = listEl.querySelectorAll('.skillgarden-add-btn');
|
|
1321
|
+
for (var j = 0; j < addButtons.length; j++) {
|
|
1322
|
+
addButtons[j].addEventListener('click', function (ev) {
|
|
1323
|
+
var btn = ev.currentTarget;
|
|
1324
|
+
var category = btn.getAttribute('data-category');
|
|
1325
|
+
var id = btn.getAttribute('data-id');
|
|
1326
|
+
var originalText = btn.textContent;
|
|
1327
|
+
btn.disabled = true;
|
|
1328
|
+
btn.textContent = 'Adding…';
|
|
1329
|
+
performSkillgardenAdd(name, category, id, btn, originalText, false);
|
|
1330
|
+
});
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1026
1334
|
function wireSkillsHandlers(name) {
|
|
1027
1335
|
var panel = skillsPanelEl();
|
|
1028
1336
|
if (!panel) return;
|
|
1029
1337
|
var editingSkillId = null;
|
|
1030
1338
|
|
|
1339
|
+
loadSkillgardenCatalogSection(name);
|
|
1340
|
+
var skillgardenFilterInput = panel.querySelector('#skillgardenFilterInput');
|
|
1341
|
+
if (skillgardenFilterInput) {
|
|
1342
|
+
skillgardenFilterInput.addEventListener('input', function () {
|
|
1343
|
+
if (!skillgardenCatalog) return;
|
|
1344
|
+
applySkillgardenCatalog(name, filteredSkillgardenCatalog(skillgardenFilterInput.value.trim()));
|
|
1345
|
+
});
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1031
1348
|
// Write/Preview toggle for the Body field — same idea as GitHub's own
|
|
1032
1349
|
// issue/PR editor: the textarea and the rendered preview show/hide
|
|
1033
1350
|
// each other, the textarea's value is the only source of truth (the
|
|
@@ -1158,7 +1475,7 @@ export const agentsConfigPageHtml = `<!doctype html>
|
|
|
1158
1475
|
|
|
1159
1476
|
function renderToolsTabHtml(cfg) {
|
|
1160
1477
|
return '<section><h3>Local tools (' + cfg.localTools.length + ')</h3>' + renderTools(cfg.localTools) +
|
|
1161
|
-
'<div class="
|
|
1478
|
+
'<div class="admin-subsection"><h4>Create an HTTP tool</h4>' + renderHttpToolFormHtml() + '</div>' +
|
|
1162
1479
|
'</section>' +
|
|
1163
1480
|
'<section id="gatewayToolsSection">' +
|
|
1164
1481
|
'<h3>Gateway Tools <button type="button" id="gatewayToolsRefreshBtn" class="hint-btn">Refresh</button></h3>' +
|
|
@@ -1207,7 +1524,7 @@ export const agentsConfigPageHtml = `<!doctype html>
|
|
|
1207
1524
|
'<input type="text" name="name" required pattern="[a-z][a-z0-9_]*" placeholder="lookup_order_status">' +
|
|
1208
1525
|
'</label>' +
|
|
1209
1526
|
'<label>Description' +
|
|
1210
|
-
'<
|
|
1527
|
+
'<textarea name="description" id="httpToolDescriptionInput" required placeholder="What this tool does"></textarea>' +
|
|
1211
1528
|
'</label>' +
|
|
1212
1529
|
'<label>Fields' +
|
|
1213
1530
|
'<div id="httpToolFieldRows"></div>' +
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export declare class SkillgardenUnavailableError extends Error {
|
|
2
|
+
}
|
|
3
|
+
export interface SkillgardenCatalogEntry {
|
|
4
|
+
category: string;
|
|
5
|
+
id: string;
|
|
6
|
+
description: string;
|
|
7
|
+
}
|
|
8
|
+
export interface SkillgardenCatalogDetail extends SkillgardenCatalogEntry {
|
|
9
|
+
body: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function listSkillgardenCatalog(): SkillgardenCatalogEntry[];
|
|
12
|
+
export declare function readSkillgardenCatalogEntry(category: string, id: string): SkillgardenCatalogDetail;
|
|
13
|
+
/** Copies `registry/<category>/<id>/` into `agents/<agentName>/skills/<id>/`
|
|
14
|
+
* — delegates entirely to skillgarden's own `addSkill`, which already
|
|
15
|
+
* knows this exact `agents/<agent>/skills` destination convention (see
|
|
16
|
+
* its own doc comment) and already refuses (or, with `force`,
|
|
17
|
+
* overwrites) an existing destination. Returns the bare skill id actually
|
|
18
|
+
* installed — never category-qualified, matching `addSkill`'s own
|
|
19
|
+
* "category is a registry-organization concept, not forced onto what a
|
|
20
|
+
* caller now owns" convention. */
|
|
21
|
+
export declare function addSkillgardenSkillToAgent(agentName: string, category: string, id: string, force?: boolean): {
|
|
22
|
+
id: string;
|
|
23
|
+
path: string;
|
|
24
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Lets an operator browse skillgarden's own bundled skill registry from
|
|
2
|
+
// the Skills tab in /agents/config, and add one straight into an agent's
|
|
3
|
+
// agents/<name>/skills/ folder — a shortcut for the same thing
|
|
4
|
+
// `npx skillgarden add <skill> --agent <name>` already does from a
|
|
5
|
+
// terminal, surfaced in the UI instead. This module only ever *reads*
|
|
6
|
+
// the registry and delegates the actual copy to skillgarden's own
|
|
7
|
+
// exported `addSkill` (see below) — it never re-implements that logic.
|
|
8
|
+
import { createRequire } from 'node:module';
|
|
9
|
+
import { dirname, join } from 'node:path';
|
|
10
|
+
import { SkillGarden, addSkill as skillgardenAddSkill } from 'skillgarden';
|
|
11
|
+
export class SkillgardenUnavailableError extends Error {
|
|
12
|
+
}
|
|
13
|
+
// skillgarden's own CLI resolves its bundled registry/ relative to its
|
|
14
|
+
// own compiled dist/cli.js location (see its cli.ts's own
|
|
15
|
+
// DEFAULT_REGISTRY_DIR), but doesn't export that path — `addSkill`
|
|
16
|
+
// itself defaults to it internally, which is enough for *installing* a
|
|
17
|
+
// skill, but browsing/previewing the catalog needs the directory path
|
|
18
|
+
// itself. Re-derived here the same way Node's own module resolution
|
|
19
|
+
// would: from package.json's real installed location, not assumed
|
|
20
|
+
// relative to this file (skillgarden is a real npm dependency, not a
|
|
21
|
+
// vendored copy).
|
|
22
|
+
function registryDir() {
|
|
23
|
+
const require = createRequire(import.meta.url);
|
|
24
|
+
let pkgJsonPath;
|
|
25
|
+
try {
|
|
26
|
+
pkgJsonPath = require.resolve('skillgarden/package.json');
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
throw new SkillgardenUnavailableError('skillgarden is not installed — run `npm install skillgarden` to enable the skill catalog.');
|
|
30
|
+
}
|
|
31
|
+
return join(dirname(pkgJsonPath), 'registry');
|
|
32
|
+
}
|
|
33
|
+
// A large, fixed budget, not run-agent.ts's own live skillIndexBudgetTokens
|
|
34
|
+
// default — this indexes a small, fixed, bundled catalog for browsing,
|
|
35
|
+
// not an agent's own live context, so nothing here should ever silently
|
|
36
|
+
// truncate the list an operator sees.
|
|
37
|
+
const CATALOG_INDEX_BUDGET_TOKENS = 1_000_000;
|
|
38
|
+
// discoverSkillFiles namespaces registry/<category>/<skill>/SKILL.md as
|
|
39
|
+
// "<category>:<skill>" (colon-joined, one level of nesting) — split on
|
|
40
|
+
// just the first colon, not skillId itself, in case a future entry ever
|
|
41
|
+
// nests deeper than one category level.
|
|
42
|
+
function splitNamespacedName(namespacedName) {
|
|
43
|
+
const colon = namespacedName.indexOf(':');
|
|
44
|
+
return { category: namespacedName.slice(0, colon), id: namespacedName.slice(colon + 1) };
|
|
45
|
+
}
|
|
46
|
+
function catalogGarden() {
|
|
47
|
+
return new SkillGarden({ dirs: [registryDir()], indexBudgetTokens: CATALOG_INDEX_BUDGET_TOKENS });
|
|
48
|
+
}
|
|
49
|
+
export function listSkillgardenCatalog() {
|
|
50
|
+
const { included } = catalogGarden().buildIndex();
|
|
51
|
+
return included
|
|
52
|
+
.map((entry) => ({ ...splitNamespacedName(entry.name), description: entry.description }))
|
|
53
|
+
.sort((a, b) => (a.category === b.category ? a.id.localeCompare(b.id) : a.category.localeCompare(b.category)));
|
|
54
|
+
}
|
|
55
|
+
export function readSkillgardenCatalogEntry(category, id) {
|
|
56
|
+
const garden = catalogGarden();
|
|
57
|
+
garden.buildIndex();
|
|
58
|
+
// load() throws a plain Error for an unindexed name — same "not found"
|
|
59
|
+
// shape a bad category/id combination should surface as; the caller
|
|
60
|
+
// (adapters/http.ts) maps any throw here to 404, so there's nothing
|
|
61
|
+
// more specific to catch and re-throw.
|
|
62
|
+
const loaded = garden.load(`${category}:${id}`);
|
|
63
|
+
return { category, id, description: loaded.description, body: loaded.body };
|
|
64
|
+
}
|
|
65
|
+
/** Copies `registry/<category>/<id>/` into `agents/<agentName>/skills/<id>/`
|
|
66
|
+
* — delegates entirely to skillgarden's own `addSkill`, which already
|
|
67
|
+
* knows this exact `agents/<agent>/skills` destination convention (see
|
|
68
|
+
* its own doc comment) and already refuses (or, with `force`,
|
|
69
|
+
* overwrites) an existing destination. Returns the bare skill id actually
|
|
70
|
+
* installed — never category-qualified, matching `addSkill`'s own
|
|
71
|
+
* "category is a registry-organization concept, not forced onto what a
|
|
72
|
+
* caller now owns" convention. */
|
|
73
|
+
export function addSkillgardenSkillToAgent(agentName, category, id, force = false) {
|
|
74
|
+
const result = skillgardenAddSkill({ skill: `${category}/${id}`, agent: agentName, registryDir: registryDir(), force });
|
|
75
|
+
return { id, path: join(result.destination, 'SKILL.md') };
|
|
76
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "loopengine",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Runtime for tool-using AI agents with a transparent ReAct loop.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -78,7 +78,7 @@
|
|
|
78
78
|
"mcpplug": "^0.0.1",
|
|
79
79
|
"openai": "^7.4.0",
|
|
80
80
|
"sessionknit": "^0.0.2",
|
|
81
|
-
"skillgarden": "^0.0.
|
|
81
|
+
"skillgarden": "^0.0.9",
|
|
82
82
|
"toollane": "^0.0.1",
|
|
83
83
|
"typescript": "^5.7.0",
|
|
84
84
|
"yaml": "^2.6.0"
|