loopengine 0.1.4 → 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 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":8000,"ratio":0.00025}
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
  ```
@@ -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
- const skillGarden = new SkillGarden({ dirs: [...skillsDirs, systemSkillsDir], indexBudgetTokens: config.skillIndexBudgetTokens ?? 200 });
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
- const budgetTracker = new BudgetTracker({ budgetTokens: config.contextBudgetTokens ?? 8000 });
325
- const compactor = new Compactor({ budgetTokens: config.contextBudgetTokens ?? 8000, softThreshold: budgetTracker.softThreshold, tailMessages });
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
@@ -27,12 +27,16 @@ export { listQuestions, answerQuestion as resolvePendingQuestion, findQuestion,
27
27
  export type { PendingQuestion } from './core/system-tools/index.js';
28
28
  export { editAgentFile, AgentEditNotSupportedError, AgentFileNotFoundError } from './web/agent-file-admin.js';
29
29
  export type { AgentEditableFields, AgentEditResult } from './web/agent-file-admin.js';
30
+ export { createHttpTool, HttpToolNameError, HttpToolExistsError, HttpToolIndexShapeError } from './web/http-tool-admin.js';
31
+ export type { HttpToolSpec, HttpToolField } from './web/http-tool-admin.js';
30
32
  export { agentsConfigPageHtml } from './web/agents-config-page.js';
31
33
  export { agentsListPageHtml } from './web/agents-list-page.js';
32
34
  export { globalConfigPageHtml } from './web/global-config-page.js';
33
35
  export { playgroundHtml } from './web/playground.js';
34
36
  export { readSkill, writeSkill, deleteSkill, SkillInvalidIdError, SkillNotFoundError } from './web/skills-admin.js';
35
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';
36
40
  export { readActauthConfig, addActauthRule, updateActauthRule, removeActauthRule, setDefaultDecision, ActauthRuleExistsError, ActauthRuleNotFoundError, } from './web/actauth-admin.js';
37
41
  export type { ActauthRuleInput, ActauthConfigView } from './web/actauth-admin.js';
38
42
  export { describeModelProviders, describeGateways } from './web/global-config.js';
package/dist/index.js CHANGED
@@ -34,11 +34,13 @@ export { WebhookNotifier } from './core/http-notify-triggers/webhook.js';
34
34
  // question in-process, aliased to avoid colliding with that name.
35
35
  export { listQuestions, answerQuestion as resolvePendingQuestion, findQuestion, createAskUserTool } from './core/system-tools/index.js';
36
36
  export { editAgentFile, AgentEditNotSupportedError, AgentFileNotFoundError } from './web/agent-file-admin.js';
37
+ export { createHttpTool, HttpToolNameError, HttpToolExistsError, HttpToolIndexShapeError } from './web/http-tool-admin.js';
37
38
  export { agentsConfigPageHtml } from './web/agents-config-page.js';
38
39
  export { agentsListPageHtml } from './web/agents-list-page.js';
39
40
  export { globalConfigPageHtml } from './web/global-config-page.js';
40
41
  export { playgroundHtml } from './web/playground.js';
41
42
  export { readSkill, writeSkill, deleteSkill, SkillInvalidIdError, SkillNotFoundError } from './web/skills-admin.js';
43
+ export { listSkillgardenCatalog, readSkillgardenCatalogEntry, addSkillgardenSkillToAgent, SkillgardenUnavailableError } from './web/skillgarden-admin.js';
42
44
  export { readActauthConfig, addActauthRule, updateActauthRule, removeActauthRule, setDefaultDecision, ActauthRuleExistsError, ActauthRuleNotFoundError, } from './web/actauth-admin.js';
43
45
  export { describeModelProviders, describeGateways } from './web/global-config.js';
44
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,6 +131,18 @@ export const agentsConfigPageHtml = `<!doctype html>
131
131
  letter-spacing: normal;
132
132
  padding: 2px 8px;
133
133
  }
134
+ .admin-subsection {
135
+ margin-top: 14px;
136
+ padding-top: 14px;
137
+ border-top: 1px dashed light-dark(#eee, #2a2a2e);
138
+ }
139
+ .admin-subsection h4 {
140
+ font-size: 12px;
141
+ text-transform: uppercase;
142
+ letter-spacing: 0.04em;
143
+ color: light-dark(#666, #999);
144
+ margin: 0 0 8px;
145
+ }
134
146
  pre {
135
147
  background: light-dark(#fff, #26262b);
136
148
  border: 1px solid light-dark(#ddd, #3a3a3e);
@@ -297,6 +309,84 @@ export const agentsConfigPageHtml = `<!doctype html>
297
309
  stretches the checkbox to the full row width, which visually
298
310
  overlaps the description text under it instead of sitting beside it. */
299
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
+ }
300
390
  </style>
301
391
  </head>
302
392
  <body>
@@ -587,15 +677,31 @@ export const agentsConfigPageHtml = `<!doctype html>
587
677
  '<dt>tenantFor</dt><dd>' + badge(cfg.tenantFor.indexOf('custom') === 0 ? 'custom' : 'default') + ' <span class="muted">' + escapeHtml(cfg.tenantFor) + '</span></dd>' +
588
678
  '<dt>isSafeTool</dt><dd>' + badge(cfg.isSafeTool === 'custom' ? 'custom' : 'default') + ' <span class="muted">' + escapeHtml(cfg.isSafeTool) + '</span></dd>' +
589
679
  '</dl></section>' +
590
- '<section><h3>Limits &amp; budgets</h3><dl class="kv">' +
591
- '<dt>maxTurns</dt><dd>' + escapeHtml(cfg.maxTurns) + '</dd>' +
592
- '<dt>contextBudgetTokens</dt><dd>' + escapeHtml(cfg.contextBudgetTokens) + '</dd>' +
593
- '<dt>skillIndexBudgetTokens</dt><dd>' + escapeHtml(cfg.skillIndexBudgetTokens) + '</dd>' +
594
- '<dt>skillsDirs</dt><dd>' + escapeHtml(cfg.skillsDirs.join(', ') || '(none)') + '</dd>' +
595
- '</dl></section>';
680
+ '<section><h3>Limits &amp; 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>';
596
702
  }
597
703
 
598
- // Wires the System prompt / Model Edit buttons rendered by
704
+ // Wires the System prompt / Model / Limits & budgets Edit buttons rendered by
599
705
  // renderOverviewHtml above — called every time that HTML gets (re)set
600
706
  // (renderDetail, refreshOverviewPanel, refreshSkillsDependentPanels),
601
707
  // same pattern every other tab's own wireXHandlers already follows.
@@ -696,6 +802,53 @@ export const agentsConfigPageHtml = `<!doctype html>
696
802
  });
697
803
  });
698
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
+ }
699
852
  }
700
853
 
701
854
  // ---- Overview's own Actauth section: read-only, from cfg.permissions
@@ -1004,18 +1157,194 @@ export const agentsConfigPageHtml = `<!doctype html>
1004
1157
  '<button type="submit" id="skillSubmitBtn">Add</button>' +
1005
1158
  '<button type="button" id="skillCancelBtn" style="display:none">Cancel</button>' +
1006
1159
  '<div id="skillFormError" class="error"></div>' +
1007
- '</form></section>';
1160
+ '</form></section>' +
1161
+ '<section><h3>Browse skillgarden catalog</h3>' +
1162
+ '<input type="text" id="skillgardenFilterInput" placeholder="Filter by name or category&hellip;">' +
1163
+ '<div id="skillgardenCatalogList"><p class="hint">Loading&hellip;</p></div>' +
1164
+ '</section>';
1008
1165
  }
1009
1166
 
1010
1167
  function skillsPanelEl() {
1011
1168
  return detail.querySelector('[data-tab-panel="skills"]');
1012
1169
  }
1013
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&hellip;</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
+
1014
1334
  function wireSkillsHandlers(name) {
1015
1335
  var panel = skillsPanelEl();
1016
1336
  if (!panel) return;
1017
1337
  var editingSkillId = null;
1018
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
+
1019
1348
  // Write/Preview toggle for the Body field — same idea as GitHub's own
1020
1349
  // issue/PR editor: the textarea and the rendered preview show/hide
1021
1350
  // each other, the textarea's value is the only source of truth (the
@@ -1145,7 +1474,9 @@ export const agentsConfigPageHtml = `<!doctype html>
1145
1474
  // describeGatewayTools), a real cost not worth paying up front. ----
1146
1475
 
1147
1476
  function renderToolsTabHtml(cfg) {
1148
- return '<section><h3>Local tools (' + cfg.localTools.length + ')</h3>' + renderTools(cfg.localTools) + '</section>' +
1477
+ return '<section><h3>Local tools (' + cfg.localTools.length + ')</h3>' + renderTools(cfg.localTools) +
1478
+ '<div class="admin-subsection"><h4>Create an HTTP tool</h4>' + renderHttpToolFormHtml() + '</div>' +
1479
+ '</section>' +
1149
1480
  '<section id="gatewayToolsSection">' +
1150
1481
  '<h3>Gateway Tools <button type="button" id="gatewayToolsRefreshBtn" class="hint-btn">Refresh</button></h3>' +
1151
1482
  '<div id="gatewayToolsContent"><p class="hint">Loading&hellip;</p></div>' +
@@ -1153,6 +1484,170 @@ export const agentsConfigPageHtml = `<!doctype html>
1153
1484
  '<section><h3>Agent as Tools (' + cfg.agentAsTools.length + ')</h3>' + renderTools(cfg.agentAsTools) + '</section>';
1154
1485
  }
1155
1486
 
1487
+ // ---- Create an HTTP tool: generates a real agents/:name/tools/<tool>.ts
1488
+ // file from this form (loopengine's own createHttpTool), never arbitrary
1489
+ // code — only one shape, a parameterized HTTP call. See
1490
+ // web/http-tool-admin.ts's own header comment for why this is the line
1491
+ // this repo draws instead of, say, storing code in a SKILL.md and
1492
+ // executing it at request time. ----
1493
+
1494
+ var httpToolFieldRowCount = 0;
1495
+ var httpToolHeaderRowCount = 0;
1496
+
1497
+ function renderHttpToolFieldRow(index) {
1498
+ return '<div class="http-tool-field-row" data-row="' + index + '">' +
1499
+ '<input type="text" class="http-tool-field-name" placeholder="field_name" pattern="[a-zA-Z_][a-zA-Z0-9_]*">' +
1500
+ '<select class="http-tool-field-type">' +
1501
+ '<option value="string">string</option>' +
1502
+ '<option value="number">number</option>' +
1503
+ '<option value="boolean">boolean</option>' +
1504
+ '</select>' +
1505
+ '<input type="text" class="http-tool-field-description" placeholder="description (optional)">' +
1506
+ '<label class="http-tool-inline-label"><input type="checkbox" class="http-tool-field-required" checked> required</label>' +
1507
+ '<button type="button" class="delete-btn http-tool-remove-row" title="Remove this field" aria-label="Remove this field">Delete</button>' +
1508
+ '</div>';
1509
+ }
1510
+
1511
+ function renderHttpToolHeaderRow(index) {
1512
+ return '<div class="http-tool-header-row" data-row="' + index + '">' +
1513
+ '<input type="text" class="http-tool-header-key" placeholder="Header-Name">' +
1514
+ '<input type="text" class="http-tool-header-value" placeholder="value, {field}, or {{ENV_VAR}}">' +
1515
+ '<button type="button" class="delete-btn http-tool-remove-row" title="Remove this header" aria-label="Remove this header">Delete</button>' +
1516
+ '</div>';
1517
+ }
1518
+
1519
+ function renderHttpToolFormHtml() {
1520
+ httpToolFieldRowCount = 0;
1521
+ httpToolHeaderRowCount = 0;
1522
+ return '<form class="add-source" id="httpToolForm">' +
1523
+ '<label>Name <span class="hint">(snake_case, e.g. lookup_order_status)</span>' +
1524
+ '<input type="text" name="name" required pattern="[a-z][a-z0-9_]*" placeholder="lookup_order_status">' +
1525
+ '</label>' +
1526
+ '<label>Description' +
1527
+ '<textarea name="description" id="httpToolDescriptionInput" required placeholder="What this tool does"></textarea>' +
1528
+ '</label>' +
1529
+ '<label>Fields' +
1530
+ '<div id="httpToolFieldRows"></div>' +
1531
+ '<button type="button" id="httpToolAddFieldBtn" class="hint-btn">Add field</button>' +
1532
+ '</label>' +
1533
+ '<label>Method &amp; URL <span class="hint">(use {field} for a field defined above)</span>' +
1534
+ '<div class="http-tool-method-url">' +
1535
+ '<select name="method">' +
1536
+ '<option value="GET">GET</option>' +
1537
+ '<option value="POST">POST</option>' +
1538
+ '<option value="PUT">PUT</option>' +
1539
+ '<option value="PATCH">PATCH</option>' +
1540
+ '<option value="DELETE">DELETE</option>' +
1541
+ '</select>' +
1542
+ '<input type="text" name="url" required placeholder="https://api.example.com/orders/{orderId}">' +
1543
+ '</div>' +
1544
+ '</label>' +
1545
+ '<label>Headers <span class="hint">(a value may reference {field} or {{ENV_VAR}} &mdash; never paste a real secret here)</span>' +
1546
+ '<div id="httpToolHeaderRows"></div>' +
1547
+ '<button type="button" id="httpToolAddHeaderBtn" class="hint-btn">Add header</button>' +
1548
+ '</label>' +
1549
+ '<label class="http-tool-inline-label"><input type="checkbox" name="sendFieldsAsJsonBody"> Send fields as a JSON body (POST/PUT/PATCH only)</label>' +
1550
+ '<label>Response JSON path <span class="hint">(optional &mdash; e.g. data.status; leave blank to return the raw response text)</span>' +
1551
+ '<input type="text" name="responseJsonPath" placeholder="data.status">' +
1552
+ '</label>' +
1553
+ '<label>Grant permission <span class="hint">(optional &mdash; leave blank to leave this tool at actauth&#39;s own default, typically deny)</span>' +
1554
+ '<select name="decision">' +
1555
+ '<option value="">(leave unset)</option>' +
1556
+ '<option value="allow">allow</option>' +
1557
+ '<option value="ask">ask</option>' +
1558
+ '<option value="deny">deny</option>' +
1559
+ '</select>' +
1560
+ '</label>' +
1561
+ '<button type="submit" id="httpToolSubmitBtn">Create tool</button>' +
1562
+ '<div id="httpToolError" class="error"></div>' +
1563
+ '</form>';
1564
+ }
1565
+
1566
+ function wireHttpToolRemoveButton(row) {
1567
+ var btn = row.querySelector('.http-tool-remove-row');
1568
+ btn.addEventListener('click', function () {
1569
+ row.parentNode.removeChild(row);
1570
+ });
1571
+ }
1572
+
1573
+ function wireHttpToolFormHandlers(name) {
1574
+ var form = detail.querySelector('#httpToolForm');
1575
+ if (!form) return;
1576
+
1577
+ var fieldRows = detail.querySelector('#httpToolFieldRows');
1578
+ var headerRows = detail.querySelector('#httpToolHeaderRows');
1579
+ var addFieldBtn = detail.querySelector('#httpToolAddFieldBtn');
1580
+ var addHeaderBtn = detail.querySelector('#httpToolAddHeaderBtn');
1581
+ var errorEl = detail.querySelector('#httpToolError');
1582
+ var submitBtn = detail.querySelector('#httpToolSubmitBtn');
1583
+
1584
+ addFieldBtn.addEventListener('click', function () {
1585
+ fieldRows.insertAdjacentHTML('beforeend', renderHttpToolFieldRow(httpToolFieldRowCount++));
1586
+ wireHttpToolRemoveButton(fieldRows.lastElementChild);
1587
+ });
1588
+ addHeaderBtn.addEventListener('click', function () {
1589
+ headerRows.insertAdjacentHTML('beforeend', renderHttpToolHeaderRow(httpToolHeaderRowCount++));
1590
+ wireHttpToolRemoveButton(headerRows.lastElementChild);
1591
+ });
1592
+
1593
+ form.addEventListener('submit', function (ev) {
1594
+ ev.preventDefault();
1595
+ errorEl.textContent = '';
1596
+
1597
+ var fields = [];
1598
+ var fieldRowEls = fieldRows.querySelectorAll('.http-tool-field-row');
1599
+ for (var i = 0; i < fieldRowEls.length; i++) {
1600
+ var row = fieldRowEls[i];
1601
+ var fieldName = row.querySelector('.http-tool-field-name').value.trim();
1602
+ if (!fieldName) continue;
1603
+ fields.push({
1604
+ name: fieldName,
1605
+ type: row.querySelector('.http-tool-field-type').value,
1606
+ description: row.querySelector('.http-tool-field-description').value.trim() || undefined,
1607
+ required: row.querySelector('.http-tool-field-required').checked,
1608
+ });
1609
+ }
1610
+
1611
+ var headers = [];
1612
+ var headerRowEls = headerRows.querySelectorAll('.http-tool-header-row');
1613
+ for (var j = 0; j < headerRowEls.length; j++) {
1614
+ var hrow = headerRowEls[j];
1615
+ var headerKey = hrow.querySelector('.http-tool-header-key').value.trim();
1616
+ if (!headerKey) continue;
1617
+ headers.push({ key: headerKey, value: hrow.querySelector('.http-tool-header-value').value });
1618
+ }
1619
+
1620
+ var formData = new FormData(form);
1621
+ var payload = {
1622
+ name: formData.get('name'),
1623
+ description: formData.get('description'),
1624
+ fields: fields,
1625
+ method: formData.get('method'),
1626
+ url: formData.get('url'),
1627
+ headers: headers,
1628
+ sendFieldsAsJsonBody: formData.get('sendFieldsAsJsonBody') === 'on',
1629
+ responseJsonPath: formData.get('responseJsonPath') ? formData.get('responseJsonPath') : undefined,
1630
+ decision: formData.get('decision') ? formData.get('decision') : undefined,
1631
+ };
1632
+
1633
+ submitBtn.disabled = true;
1634
+ fetch('/agents/' + encodeURIComponent(name) + '/tools/http', {
1635
+ method: 'POST',
1636
+ headers: { 'content-type': 'application/json' },
1637
+ body: JSON.stringify(payload),
1638
+ })
1639
+ .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
1640
+ .then(function (result) {
1641
+ if (!result.ok) throw new Error(result.body.error || 'request failed');
1642
+ selectAgent(name);
1643
+ })
1644
+ .catch(function (err) {
1645
+ errorEl.textContent = err.message;
1646
+ submitBtn.disabled = false;
1647
+ });
1648
+ });
1649
+ }
1650
+
1156
1651
  // Removing a source used to be one "Remove" button per source, at the
1157
1652
  // bottom of the card — dropping every tool it produces at once, with
1158
1653
  // no way to remove just one. Each slug now gets its own remove icon
@@ -1565,6 +2060,7 @@ export const agentsConfigPageHtml = `<!doctype html>
1565
2060
 
1566
2061
  wireSkillsHandlers(cfg.name);
1567
2062
  wireOverviewHandlers(cfg.name);
2063
+ wireHttpToolFormHandlers(cfg.name);
1568
2064
  var gatewayRefreshBtn = detail.querySelector('#gatewayToolsRefreshBtn');
1569
2065
  if (gatewayRefreshBtn) {
1570
2066
  gatewayRefreshBtn.addEventListener('click', function () { loadGatewayTab(cfg.name, true); });
@@ -0,0 +1,37 @@
1
+ import type { ToolDefinition } from '../core/agent-config.js';
2
+ export declare class HttpToolNameError extends Error {
3
+ }
4
+ export declare class HttpToolExistsError extends Error {
5
+ }
6
+ export declare class HttpToolIndexShapeError extends Error {
7
+ }
8
+ export interface HttpToolField {
9
+ name: string;
10
+ type: 'string' | 'number' | 'boolean';
11
+ description?: string;
12
+ required: boolean;
13
+ }
14
+ export interface HttpToolSpec {
15
+ name: string;
16
+ description: string;
17
+ fields: HttpToolField[];
18
+ method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
19
+ url: string;
20
+ headers: {
21
+ key: string;
22
+ value: string;
23
+ }[];
24
+ sendFieldsAsJsonBody: boolean;
25
+ responseJsonPath?: string;
26
+ }
27
+ /** Generates agents/<agentName>/tools/<spec.name>.ts, registers it in
28
+ * that agent's tools/index.ts, then imports the file it just wrote and
29
+ * returns the real, callable ToolDefinition object — so the route
30
+ * handler (adapters/http.ts's POST /agents/:name/tools/http) can splice
31
+ * it straight into the live agent registry (core/agent-registry.ts's
32
+ * updateAgent) without guessing an export name or re-deriving anything
33
+ * this module already knows. */
34
+ export declare function createHttpTool(agentName: string, spec: HttpToolSpec): Promise<{
35
+ path: string;
36
+ tool: ToolDefinition;
37
+ }>;
@@ -0,0 +1,223 @@
1
+ // Lets an operator generate a real, hand-editable tool file from a
2
+ // constrained form — the Tools tab's fourth section, alongside Local
3
+ // tools/Subagents/Gateway Tools — instead of writing agents/<name>/
4
+ // tools/<tool>.ts by hand. Deliberately narrow: the only thing this
5
+ // generates is a parameterized HTTP call (method, a {field}-templated
6
+ // URL, headers, an optional JSON body/response-path mapping), never
7
+ // arbitrary code. A SKILL.md-style "store code as markdown, eval it at
8
+ // request time" version of this was considered and rejected: SKILL.md
9
+ // is safe specifically because its content is only ever read by the
10
+ // model, never executed — this generates a normal, readable, type-
11
+ // checked .ts file instead, the same class of artifact a human would
12
+ // have written by hand, just templated for one well-understood shape.
13
+ //
14
+ // Field/URL/header substitution is compiled into the *generated code*
15
+ // (a template literal with ${...} interpolations), not resolved at
16
+ // generation time — so a value is only ever combined with the URL via
17
+ // real encodeURIComponent, at call time, off whatever the model actually
18
+ // passed as input. A header value may also reference {{ENV_VAR}} — the
19
+ // generated code reads that from process.env at call time; the literal
20
+ // secret itself is never accepted by this module, persisted to the
21
+ // generated file, or echoed back through the admin API, the same
22
+ // "always env-sourced, never a config literal" rule SendEmail/
23
+ // HttpNotifierConfig's own secrets already follow (core/agent-config.ts).
24
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
25
+ import { join } from 'node:path';
26
+ import { pathToFileURL } from 'node:url';
27
+ import { agentDir } from '../core/gateway-tools.js';
28
+ export class HttpToolNameError extends Error {
29
+ }
30
+ export class HttpToolExistsError extends Error {
31
+ }
32
+ export class HttpToolIndexShapeError extends Error {
33
+ }
34
+ const TOOL_NAME_PATTERN = /^[a-z][a-z0-9_]*$/;
35
+ const FIELD_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
36
+ const ENV_VAR_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
37
+ // Same escaping bin/cli.ts's own local tsStringLiteral uses for a
38
+ // single-quoted literal — duplicated here rather than imported, since
39
+ // it's two lines and not worth a cross-module export for.
40
+ function tsStringLiteral(value) {
41
+ return "'" + value.replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "'";
42
+ }
43
+ // snake_case tool name -> camelCase export identifier, e.g.
44
+ // "lookup_order_status" -> "lookupOrderStatus" — matches every
45
+ // hand-written tool file's own convention (see agents/customer-service/
46
+ // tools/*.ts: issue_refund.ts exports `issueRefund`, etc.).
47
+ function toCamelCase(snakeCase) {
48
+ return snakeCase.replace(/_([a-z0-9])/g, (_, c) => c.toUpperCase());
49
+ }
50
+ // Escapes a literal segment for safe interpolation inside a generated
51
+ // JS template literal (backtick string) — the three characters that
52
+ // would otherwise break out of it or start a nested interpolation.
53
+ function escapeTemplateLiteralSegment(segment) {
54
+ return segment.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
55
+ }
56
+ /** Compiles a "{field}" template string into the *inside* of a JS
57
+ * template literal (caller wraps it in backticks) — each {field}
58
+ * becomes `${wrap(fieldExpr)}`, everything else is a literal segment,
59
+ * escaped for backtick safety. Throws if a referenced field isn't one
60
+ * of `fieldNames` — a typo'd placeholder should fail at generation
61
+ * time, not silently produce "undefined" in a live request later. */
62
+ function compileFieldTemplate(template, fieldNames, wrap) {
63
+ let out = '';
64
+ let lastIndex = 0;
65
+ const pattern = /\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g;
66
+ let match;
67
+ while ((match = pattern.exec(template))) {
68
+ out += escapeTemplateLiteralSegment(template.slice(lastIndex, match.index));
69
+ const field = match[1];
70
+ if (!fieldNames.has(field)) {
71
+ throw new HttpToolNameError(`Template references {${field}}, which isn't one of this tool's own fields.`);
72
+ }
73
+ out += '${' + wrap(`input.${field}`) + '}';
74
+ lastIndex = pattern.lastIndex;
75
+ }
76
+ out += escapeTemplateLiteralSegment(template.slice(lastIndex));
77
+ return out;
78
+ }
79
+ function generateToolCode(spec) {
80
+ const fieldNames = new Set(spec.fields.map((f) => f.name));
81
+ for (const field of spec.fields) {
82
+ if (!FIELD_NAME_PATTERN.test(field.name)) {
83
+ throw new HttpToolNameError(`Field name "${field.name}" must be a valid identifier (letters, digits, underscore, not starting with a digit).`);
84
+ }
85
+ }
86
+ // Every distinct {{ENV_VAR}} referenced across header values becomes
87
+ // one `const NAME = process.env.NAME` read (with a clear failure if
88
+ // unset), generated once up front — cleaner than inlining a
89
+ // read-and-check expression at every point of use, and it means a
90
+ // missing secret fails loudly the moment the tool actually runs, not
91
+ // silently as an empty header value.
92
+ const envVars = new Set();
93
+ const headerEntries = spec.headers.map(({ key, value }) => {
94
+ let compiled = '';
95
+ let lastIndex = 0;
96
+ const pattern = /\{\{([A-Z_][A-Z0-9_]*)\}\}|\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g;
97
+ let match;
98
+ while ((match = pattern.exec(value))) {
99
+ compiled += escapeTemplateLiteralSegment(value.slice(lastIndex, match.index));
100
+ if (match[1]) {
101
+ if (!ENV_VAR_PATTERN.test(match[1])) {
102
+ throw new HttpToolNameError(`"{{${match[1]}}}" isn't a valid env var name (uppercase letters, digits, underscore).`);
103
+ }
104
+ envVars.add(match[1]);
105
+ compiled += '${' + match[1] + '}';
106
+ }
107
+ else {
108
+ const field = match[2];
109
+ if (!fieldNames.has(field)) {
110
+ throw new HttpToolNameError(`Header "${key}" references {${field}}, which isn't one of this tool's own fields.`);
111
+ }
112
+ compiled += '${String(input.' + field + ')}';
113
+ }
114
+ lastIndex = pattern.lastIndex;
115
+ }
116
+ compiled += escapeTemplateLiteralSegment(value.slice(lastIndex));
117
+ return { key, compiled };
118
+ });
119
+ const urlInner = compileFieldTemplate(spec.url, fieldNames, (expr) => `encodeURIComponent(String(${expr}))`);
120
+ const properties = spec.fields
121
+ .map((f) => ` ${JSON.stringify(f.name)}: { type: ${JSON.stringify(f.type)}${f.description ? `, description: ${JSON.stringify(f.description)}` : ''} },`)
122
+ .join('\n');
123
+ const required = spec.fields.filter((f) => f.required).map((f) => JSON.stringify(f.name));
124
+ const envReads = [...envVars]
125
+ .map((name) => ` const ${name} = process.env.${name}\n if (!${name}) throw new Error(${tsStringLiteral(`${spec.name}: ${name} is not set`)})\n`)
126
+ .join('');
127
+ const headersObj = headerEntries.length === 0
128
+ ? ''
129
+ : ` headers: {\n${headerEntries.map(({ key, compiled }) => ` ${JSON.stringify(key)}: \`${compiled}\`,`).join('\n')}\n },\n`;
130
+ const bodyLine = spec.sendFieldsAsJsonBody && spec.method !== 'GET' && spec.method !== 'DELETE' ? ' body: JSON.stringify(input),\n' : '';
131
+ const responseHandling = spec.responseJsonPath
132
+ ? (() => {
133
+ const segments = spec.responseJsonPath.split('.').filter(Boolean);
134
+ for (const seg of segments) {
135
+ if (!FIELD_NAME_PATTERN.test(seg)) {
136
+ throw new HttpToolNameError(`responseJsonPath segment "${seg}" must be a valid identifier.`);
137
+ }
138
+ }
139
+ const access = segments.map((s) => `?.${s}`).join('');
140
+ return ` const body = await res.json()\n return JSON.stringify(body${access} ?? null)\n`;
141
+ })()
142
+ : ' return await res.text()\n';
143
+ return `// Generated by the Admin UI's HTTP tool builder (Tools tab) — a
144
+ // parameterized HTTP call, not hand-written logic. Edit freely for
145
+ // anything this template can't express; the Admin UI only ever
146
+ // regenerates a tool it created itself, never overwrites a file it
147
+ // doesn't recognize (see web/http-tool-admin.ts's own HttpToolExistsError).
148
+ import type { ToolDefinition } from 'loopengine'
149
+
150
+ export const ${toCamelCase(spec.name)}: ToolDefinition = {
151
+ name: ${tsStringLiteral(spec.name)},
152
+ description: ${tsStringLiteral(spec.description)},
153
+ input_schema: {
154
+ type: 'object',
155
+ properties: {
156
+ ${properties}
157
+ },
158
+ required: [${required.join(', ')}],
159
+ },
160
+ execute: async (input) => {
161
+ ${envReads} const res = await fetch(\`${urlInner}\`, {
162
+ method: ${tsStringLiteral(spec.method)},
163
+ ${headersObj}${bodyLine} })
164
+ if (!res.ok) throw new Error(\`${spec.name}: HTTP \${res.status}\`)
165
+ ${responseHandling} },
166
+ }
167
+ `;
168
+ }
169
+ // tools/index.ts's own shape is deliberately simple and consistent
170
+ // across every real example in this repo (agents/customer-service/
171
+ // tools/index.ts, create-loopengine's own template) — a handful of
172
+ // named imports, then one `export const tools: ToolDefinition[] = [...]`
173
+ // array literal. This patches that exact shape with two small string
174
+ // edits (one new import line, one new array entry) rather than a full
175
+ // TS-AST rewrite; if the file has been hand-edited into something this
176
+ // pattern doesn't recognize, it refuses instead of risking corrupting it
177
+ // — same "refuse rather than guess" discipline agent-file-admin.ts's own
178
+ // doc comment already establishes for index.ts edits.
179
+ function addToolToIndex(toolsIndexPath, toolFileName, exportName) {
180
+ const source = readFileSync(toolsIndexPath, 'utf8');
181
+ const arrayMatch = source.match(/export const tools: ToolDefinition\[\] = \[([^\]]*)\]/);
182
+ if (!arrayMatch) {
183
+ throw new HttpToolIndexShapeError(`${toolsIndexPath} doesn't match the expected "export const tools: ToolDefinition[] = [...]" shape — add this tool to it by hand instead.`);
184
+ }
185
+ const importLine = `import { ${exportName} } from './${toolFileName}.js'\n`;
186
+ const lastImportMatch = [...source.matchAll(/^import .+\n/gm)].pop();
187
+ const insertAt = lastImportMatch ? lastImportMatch.index + lastImportMatch[0].length : 0;
188
+ const withImport = source.slice(0, insertAt) + importLine + source.slice(insertAt);
189
+ const existingEntries = arrayMatch[1].trim();
190
+ const newEntries = existingEntries ? `${existingEntries}, ${exportName}` : exportName;
191
+ const withEntry = withImport.replace(/export const tools: ToolDefinition\[\] = \[([^\]]*)\]/, `export const tools: ToolDefinition[] = [${newEntries}]`);
192
+ writeFileSync(toolsIndexPath, withEntry);
193
+ }
194
+ /** Generates agents/<agentName>/tools/<spec.name>.ts, registers it in
195
+ * that agent's tools/index.ts, then imports the file it just wrote and
196
+ * returns the real, callable ToolDefinition object — so the route
197
+ * handler (adapters/http.ts's POST /agents/:name/tools/http) can splice
198
+ * it straight into the live agent registry (core/agent-registry.ts's
199
+ * updateAgent) without guessing an export name or re-deriving anything
200
+ * this module already knows. */
201
+ export async function createHttpTool(agentName, spec) {
202
+ if (!TOOL_NAME_PATTERN.test(spec.name)) {
203
+ throw new HttpToolNameError(`Tool name must be lowercase snake_case (e.g. "lookup_order_status") — got "${spec.name}"`);
204
+ }
205
+ const toolsDir = join(agentDir(agentName), 'tools');
206
+ const toolPath = join(toolsDir, `${spec.name}.ts`);
207
+ if (existsSync(toolPath)) {
208
+ throw new HttpToolExistsError(`agents/${agentName}/tools/${spec.name}.ts already exists — pick a different name or edit it directly.`);
209
+ }
210
+ const code = generateToolCode(spec);
211
+ mkdirSync(toolsDir, { recursive: true });
212
+ writeFileSync(toolPath, code);
213
+ const indexPath = join(toolsDir, 'index.ts');
214
+ if (existsSync(indexPath)) {
215
+ addToolToIndex(indexPath, spec.name, toCamelCase(spec.name));
216
+ }
217
+ else {
218
+ writeFileSync(indexPath, `import type { ToolDefinition } from 'loopengine'\nimport { ${toCamelCase(spec.name)} } from './${spec.name}.js'\n\nexport const tools: ToolDefinition[] = [${toCamelCase(spec.name)}]\n`);
219
+ }
220
+ const exportName = toCamelCase(spec.name);
221
+ const mod = (await import(pathToFileURL(toolPath).href));
222
+ return { path: toolPath, tool: mod[exportName] };
223
+ }
@@ -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.4",
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.5",
81
+ "skillgarden": "^0.0.9",
82
82
  "toollane": "^0.0.1",
83
83
  "typescript": "^5.7.0",
84
84
  "yaml": "^2.6.0"