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 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) + '</section>' +
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&hellip;</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 &amp; 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}} &mdash; 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 &mdash; 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 &mdash; leave blank to leave this tool at actauth&#39;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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopengine",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "description": "Runtime for tool-using AI agents with a transparent ReAct loop.",
6
6
  "license": "MIT",