loopengine 0.1.4 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/web/agents-config-page.js +180 -1
- package/dist/web/http-tool-admin.d.ts +37 -0
- package/dist/web/http-tool-admin.js +223 -0
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -27,6 +27,8 @@ 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';
|
package/dist/index.js
CHANGED
|
@@ -34,6 +34,7 @@ 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';
|
|
@@ -131,6 +131,18 @@ export const agentsConfigPageHtml = `<!doctype html>
|
|
|
131
131
|
letter-spacing: normal;
|
|
132
132
|
padding: 2px 8px;
|
|
133
133
|
}
|
|
134
|
+
.http-tool-subsection {
|
|
135
|
+
margin-top: 14px;
|
|
136
|
+
padding-top: 14px;
|
|
137
|
+
border-top: 1px dashed light-dark(#eee, #2a2a2e);
|
|
138
|
+
}
|
|
139
|
+
.http-tool-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);
|
|
@@ -1145,7 +1157,9 @@ export const agentsConfigPageHtml = `<!doctype html>
|
|
|
1145
1157
|
// describeGatewayTools), a real cost not worth paying up front. ----
|
|
1146
1158
|
|
|
1147
1159
|
function renderToolsTabHtml(cfg) {
|
|
1148
|
-
return '<section><h3>Local tools (' + cfg.localTools.length + ')</h3>' + renderTools(cfg.localTools) +
|
|
1160
|
+
return '<section><h3>Local tools (' + cfg.localTools.length + ')</h3>' + renderTools(cfg.localTools) +
|
|
1161
|
+
'<div class="http-tool-subsection"><h4>Create an HTTP tool</h4>' + renderHttpToolFormHtml() + '</div>' +
|
|
1162
|
+
'</section>' +
|
|
1149
1163
|
'<section id="gatewayToolsSection">' +
|
|
1150
1164
|
'<h3>Gateway Tools <button type="button" id="gatewayToolsRefreshBtn" class="hint-btn">Refresh</button></h3>' +
|
|
1151
1165
|
'<div id="gatewayToolsContent"><p class="hint">Loading…</p></div>' +
|
|
@@ -1153,6 +1167,170 @@ export const agentsConfigPageHtml = `<!doctype html>
|
|
|
1153
1167
|
'<section><h3>Agent as Tools (' + cfg.agentAsTools.length + ')</h3>' + renderTools(cfg.agentAsTools) + '</section>';
|
|
1154
1168
|
}
|
|
1155
1169
|
|
|
1170
|
+
// ---- Create an HTTP tool: generates a real agents/:name/tools/<tool>.ts
|
|
1171
|
+
// file from this form (loopengine's own createHttpTool), never arbitrary
|
|
1172
|
+
// code — only one shape, a parameterized HTTP call. See
|
|
1173
|
+
// web/http-tool-admin.ts's own header comment for why this is the line
|
|
1174
|
+
// this repo draws instead of, say, storing code in a SKILL.md and
|
|
1175
|
+
// executing it at request time. ----
|
|
1176
|
+
|
|
1177
|
+
var httpToolFieldRowCount = 0;
|
|
1178
|
+
var httpToolHeaderRowCount = 0;
|
|
1179
|
+
|
|
1180
|
+
function renderHttpToolFieldRow(index) {
|
|
1181
|
+
return '<div class="http-tool-field-row" data-row="' + index + '">' +
|
|
1182
|
+
'<input type="text" class="http-tool-field-name" placeholder="field_name" pattern="[a-zA-Z_][a-zA-Z0-9_]*">' +
|
|
1183
|
+
'<select class="http-tool-field-type">' +
|
|
1184
|
+
'<option value="string">string</option>' +
|
|
1185
|
+
'<option value="number">number</option>' +
|
|
1186
|
+
'<option value="boolean">boolean</option>' +
|
|
1187
|
+
'</select>' +
|
|
1188
|
+
'<input type="text" class="http-tool-field-description" placeholder="description (optional)">' +
|
|
1189
|
+
'<label class="http-tool-inline-label"><input type="checkbox" class="http-tool-field-required" checked> required</label>' +
|
|
1190
|
+
'<button type="button" class="delete-btn http-tool-remove-row" title="Remove this field" aria-label="Remove this field">Delete</button>' +
|
|
1191
|
+
'</div>';
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
function renderHttpToolHeaderRow(index) {
|
|
1195
|
+
return '<div class="http-tool-header-row" data-row="' + index + '">' +
|
|
1196
|
+
'<input type="text" class="http-tool-header-key" placeholder="Header-Name">' +
|
|
1197
|
+
'<input type="text" class="http-tool-header-value" placeholder="value, {field}, or {{ENV_VAR}}">' +
|
|
1198
|
+
'<button type="button" class="delete-btn http-tool-remove-row" title="Remove this header" aria-label="Remove this header">Delete</button>' +
|
|
1199
|
+
'</div>';
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
function renderHttpToolFormHtml() {
|
|
1203
|
+
httpToolFieldRowCount = 0;
|
|
1204
|
+
httpToolHeaderRowCount = 0;
|
|
1205
|
+
return '<form class="add-source" id="httpToolForm">' +
|
|
1206
|
+
'<label>Name <span class="hint">(snake_case, e.g. lookup_order_status)</span>' +
|
|
1207
|
+
'<input type="text" name="name" required pattern="[a-z][a-z0-9_]*" placeholder="lookup_order_status">' +
|
|
1208
|
+
'</label>' +
|
|
1209
|
+
'<label>Description' +
|
|
1210
|
+
'<input type="text" name="description" required placeholder="What this tool does">' +
|
|
1211
|
+
'</label>' +
|
|
1212
|
+
'<label>Fields' +
|
|
1213
|
+
'<div id="httpToolFieldRows"></div>' +
|
|
1214
|
+
'<button type="button" id="httpToolAddFieldBtn" class="hint-btn">Add field</button>' +
|
|
1215
|
+
'</label>' +
|
|
1216
|
+
'<label>Method & URL <span class="hint">(use {field} for a field defined above)</span>' +
|
|
1217
|
+
'<div class="http-tool-method-url">' +
|
|
1218
|
+
'<select name="method">' +
|
|
1219
|
+
'<option value="GET">GET</option>' +
|
|
1220
|
+
'<option value="POST">POST</option>' +
|
|
1221
|
+
'<option value="PUT">PUT</option>' +
|
|
1222
|
+
'<option value="PATCH">PATCH</option>' +
|
|
1223
|
+
'<option value="DELETE">DELETE</option>' +
|
|
1224
|
+
'</select>' +
|
|
1225
|
+
'<input type="text" name="url" required placeholder="https://api.example.com/orders/{orderId}">' +
|
|
1226
|
+
'</div>' +
|
|
1227
|
+
'</label>' +
|
|
1228
|
+
'<label>Headers <span class="hint">(a value may reference {field} or {{ENV_VAR}} — never paste a real secret here)</span>' +
|
|
1229
|
+
'<div id="httpToolHeaderRows"></div>' +
|
|
1230
|
+
'<button type="button" id="httpToolAddHeaderBtn" class="hint-btn">Add header</button>' +
|
|
1231
|
+
'</label>' +
|
|
1232
|
+
'<label class="http-tool-inline-label"><input type="checkbox" name="sendFieldsAsJsonBody"> Send fields as a JSON body (POST/PUT/PATCH only)</label>' +
|
|
1233
|
+
'<label>Response JSON path <span class="hint">(optional — e.g. data.status; leave blank to return the raw response text)</span>' +
|
|
1234
|
+
'<input type="text" name="responseJsonPath" placeholder="data.status">' +
|
|
1235
|
+
'</label>' +
|
|
1236
|
+
'<label>Grant permission <span class="hint">(optional — leave blank to leave this tool at actauth's own default, typically deny)</span>' +
|
|
1237
|
+
'<select name="decision">' +
|
|
1238
|
+
'<option value="">(leave unset)</option>' +
|
|
1239
|
+
'<option value="allow">allow</option>' +
|
|
1240
|
+
'<option value="ask">ask</option>' +
|
|
1241
|
+
'<option value="deny">deny</option>' +
|
|
1242
|
+
'</select>' +
|
|
1243
|
+
'</label>' +
|
|
1244
|
+
'<button type="submit" id="httpToolSubmitBtn">Create tool</button>' +
|
|
1245
|
+
'<div id="httpToolError" class="error"></div>' +
|
|
1246
|
+
'</form>';
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
function wireHttpToolRemoveButton(row) {
|
|
1250
|
+
var btn = row.querySelector('.http-tool-remove-row');
|
|
1251
|
+
btn.addEventListener('click', function () {
|
|
1252
|
+
row.parentNode.removeChild(row);
|
|
1253
|
+
});
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
function wireHttpToolFormHandlers(name) {
|
|
1257
|
+
var form = detail.querySelector('#httpToolForm');
|
|
1258
|
+
if (!form) return;
|
|
1259
|
+
|
|
1260
|
+
var fieldRows = detail.querySelector('#httpToolFieldRows');
|
|
1261
|
+
var headerRows = detail.querySelector('#httpToolHeaderRows');
|
|
1262
|
+
var addFieldBtn = detail.querySelector('#httpToolAddFieldBtn');
|
|
1263
|
+
var addHeaderBtn = detail.querySelector('#httpToolAddHeaderBtn');
|
|
1264
|
+
var errorEl = detail.querySelector('#httpToolError');
|
|
1265
|
+
var submitBtn = detail.querySelector('#httpToolSubmitBtn');
|
|
1266
|
+
|
|
1267
|
+
addFieldBtn.addEventListener('click', function () {
|
|
1268
|
+
fieldRows.insertAdjacentHTML('beforeend', renderHttpToolFieldRow(httpToolFieldRowCount++));
|
|
1269
|
+
wireHttpToolRemoveButton(fieldRows.lastElementChild);
|
|
1270
|
+
});
|
|
1271
|
+
addHeaderBtn.addEventListener('click', function () {
|
|
1272
|
+
headerRows.insertAdjacentHTML('beforeend', renderHttpToolHeaderRow(httpToolHeaderRowCount++));
|
|
1273
|
+
wireHttpToolRemoveButton(headerRows.lastElementChild);
|
|
1274
|
+
});
|
|
1275
|
+
|
|
1276
|
+
form.addEventListener('submit', function (ev) {
|
|
1277
|
+
ev.preventDefault();
|
|
1278
|
+
errorEl.textContent = '';
|
|
1279
|
+
|
|
1280
|
+
var fields = [];
|
|
1281
|
+
var fieldRowEls = fieldRows.querySelectorAll('.http-tool-field-row');
|
|
1282
|
+
for (var i = 0; i < fieldRowEls.length; i++) {
|
|
1283
|
+
var row = fieldRowEls[i];
|
|
1284
|
+
var fieldName = row.querySelector('.http-tool-field-name').value.trim();
|
|
1285
|
+
if (!fieldName) continue;
|
|
1286
|
+
fields.push({
|
|
1287
|
+
name: fieldName,
|
|
1288
|
+
type: row.querySelector('.http-tool-field-type').value,
|
|
1289
|
+
description: row.querySelector('.http-tool-field-description').value.trim() || undefined,
|
|
1290
|
+
required: row.querySelector('.http-tool-field-required').checked,
|
|
1291
|
+
});
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
var headers = [];
|
|
1295
|
+
var headerRowEls = headerRows.querySelectorAll('.http-tool-header-row');
|
|
1296
|
+
for (var j = 0; j < headerRowEls.length; j++) {
|
|
1297
|
+
var hrow = headerRowEls[j];
|
|
1298
|
+
var headerKey = hrow.querySelector('.http-tool-header-key').value.trim();
|
|
1299
|
+
if (!headerKey) continue;
|
|
1300
|
+
headers.push({ key: headerKey, value: hrow.querySelector('.http-tool-header-value').value });
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
var formData = new FormData(form);
|
|
1304
|
+
var payload = {
|
|
1305
|
+
name: formData.get('name'),
|
|
1306
|
+
description: formData.get('description'),
|
|
1307
|
+
fields: fields,
|
|
1308
|
+
method: formData.get('method'),
|
|
1309
|
+
url: formData.get('url'),
|
|
1310
|
+
headers: headers,
|
|
1311
|
+
sendFieldsAsJsonBody: formData.get('sendFieldsAsJsonBody') === 'on',
|
|
1312
|
+
responseJsonPath: formData.get('responseJsonPath') ? formData.get('responseJsonPath') : undefined,
|
|
1313
|
+
decision: formData.get('decision') ? formData.get('decision') : undefined,
|
|
1314
|
+
};
|
|
1315
|
+
|
|
1316
|
+
submitBtn.disabled = true;
|
|
1317
|
+
fetch('/agents/' + encodeURIComponent(name) + '/tools/http', {
|
|
1318
|
+
method: 'POST',
|
|
1319
|
+
headers: { 'content-type': 'application/json' },
|
|
1320
|
+
body: JSON.stringify(payload),
|
|
1321
|
+
})
|
|
1322
|
+
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
|
|
1323
|
+
.then(function (result) {
|
|
1324
|
+
if (!result.ok) throw new Error(result.body.error || 'request failed');
|
|
1325
|
+
selectAgent(name);
|
|
1326
|
+
})
|
|
1327
|
+
.catch(function (err) {
|
|
1328
|
+
errorEl.textContent = err.message;
|
|
1329
|
+
submitBtn.disabled = false;
|
|
1330
|
+
});
|
|
1331
|
+
});
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1156
1334
|
// Removing a source used to be one "Remove" button per source, at the
|
|
1157
1335
|
// bottom of the card — dropping every tool it produces at once, with
|
|
1158
1336
|
// no way to remove just one. Each slug now gets its own remove icon
|
|
@@ -1565,6 +1743,7 @@ export const agentsConfigPageHtml = `<!doctype html>
|
|
|
1565
1743
|
|
|
1566
1744
|
wireSkillsHandlers(cfg.name);
|
|
1567
1745
|
wireOverviewHandlers(cfg.name);
|
|
1746
|
+
wireHttpToolFormHandlers(cfg.name);
|
|
1568
1747
|
var gatewayRefreshBtn = detail.querySelector('#gatewayToolsRefreshBtn');
|
|
1569
1748
|
if (gatewayRefreshBtn) {
|
|
1570
1749
|
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
|
+
}
|