loopengine 0.1.8 → 0.1.10
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 +1 -1
- package/dist/index.js +1 -1
- package/dist/web/agents-config-page.js +123 -7
- package/dist/web/http-tool-admin.d.ts +35 -0
- package/dist/web/http-tool-admin.js +82 -4
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -33,7 +33,7 @@ export { listQuestions, answerQuestion as resolvePendingQuestion, findQuestion,
|
|
|
33
33
|
export type { PendingQuestion } from './core/system-tools/index.js';
|
|
34
34
|
export { editAgentFile, AgentEditNotSupportedError, AgentFileNotFoundError } from './web/agent-file-admin.js';
|
|
35
35
|
export type { AgentEditableFields, AgentEditResult } from './web/agent-file-admin.js';
|
|
36
|
-
export { createHttpTool, HttpToolNameError, HttpToolExistsError, HttpToolIndexShapeError } from './web/http-tool-admin.js';
|
|
36
|
+
export { createHttpTool, updateHttpTool, readHttpToolSpec, listEditableHttpToolNames, HttpToolNameError, HttpToolExistsError, HttpToolIndexShapeError, HttpToolNotFoundError, HttpToolNotEditableError, } from './web/http-tool-admin.js';
|
|
37
37
|
export type { HttpToolSpec, HttpToolField } from './web/http-tool-admin.js';
|
|
38
38
|
export { agentsConfigPageHtml } from './web/agents-config-page.js';
|
|
39
39
|
export { agentsListPageHtml } from './web/agents-list-page.js';
|
package/dist/index.js
CHANGED
|
@@ -37,7 +37,7 @@ export { WebhookNotifier } from './core/http-notify-triggers/webhook.js';
|
|
|
37
37
|
// question in-process, aliased to avoid colliding with that name.
|
|
38
38
|
export { listQuestions, answerQuestion as resolvePendingQuestion, findQuestion, createAskUserTool } from './core/system-tools/index.js';
|
|
39
39
|
export { editAgentFile, AgentEditNotSupportedError, AgentFileNotFoundError } from './web/agent-file-admin.js';
|
|
40
|
-
export { createHttpTool, HttpToolNameError, HttpToolExistsError, HttpToolIndexShapeError } from './web/http-tool-admin.js';
|
|
40
|
+
export { createHttpTool, updateHttpTool, readHttpToolSpec, listEditableHttpToolNames, HttpToolNameError, HttpToolExistsError, HttpToolIndexShapeError, HttpToolNotFoundError, HttpToolNotEditableError, } from './web/http-tool-admin.js';
|
|
41
41
|
export { agentsConfigPageHtml } from './web/agents-config-page.js';
|
|
42
42
|
export { agentsListPageHtml } from './web/agents-list-page.js';
|
|
43
43
|
export { globalConfigPageHtml } from './web/global-config-page.js';
|
|
@@ -581,6 +581,28 @@ export const agentsConfigPageHtml = `<!doctype html>
|
|
|
581
581
|
return '<table><thead><tr><th>Name</th><th>Description</th><th>Parallel-safe</th></tr></thead><tbody>' + rows + '</tbody></table>';
|
|
582
582
|
}
|
|
583
583
|
|
|
584
|
+
// Local tools' own table, unlike renderTools above (shared by Agent as
|
|
585
|
+
// Tools, which has nothing editable through this UI) — adds an Actions
|
|
586
|
+
// column with an Edit button, but only for tools describeAgent flagged
|
|
587
|
+
// httpToolEditable: those created through this same Tools tab's HTTP
|
|
588
|
+
// tool builder (web/http-tool-admin.ts's own sidecar spec file is what
|
|
589
|
+
// that flag is actually derived from). A hand-written tools/<name>.ts
|
|
590
|
+
// has no spec to repopulate an edit form from, so it just gets a blank
|
|
591
|
+
// cell — not a button that would 404 the moment it's clicked.
|
|
592
|
+
function renderLocalTools(tools) {
|
|
593
|
+
if (!tools.length) return '<p class="muted">No tools.</p>';
|
|
594
|
+
var rows = tools.map(function (t) {
|
|
595
|
+
var actions = t.httpToolEditable
|
|
596
|
+
? '<button type="button" class="edit-http-tool-btn" data-name="' + escapeHtml(t.name) + '">Edit</button>'
|
|
597
|
+
: '';
|
|
598
|
+
return '<tr><td><code>' + escapeHtml(t.name) + '</code></td>' +
|
|
599
|
+
'<td>' + escapeHtml(t.description) + '</td>' +
|
|
600
|
+
'<td>' + (t.safe ? 'yes' : 'no') + '</td>' +
|
|
601
|
+
'<td>' + actions + '</td></tr>';
|
|
602
|
+
}).join('');
|
|
603
|
+
return '<table><thead><tr><th>Name</th><th>Description</th><th>Parallel-safe</th><th></th></tr></thead><tbody>' + rows + '</tbody></table>';
|
|
604
|
+
}
|
|
605
|
+
|
|
584
606
|
// Overview's own tools table, unlike the Tools tab's three separately-
|
|
585
607
|
// headed sections (Local tools / Gateway Tools / Agent as Tools), is
|
|
586
608
|
// one flat list (cfg.tools) — without a Type column there's no way to
|
|
@@ -1474,8 +1496,8 @@ export const agentsConfigPageHtml = `<!doctype html>
|
|
|
1474
1496
|
// describeGatewayTools), a real cost not worth paying up front. ----
|
|
1475
1497
|
|
|
1476
1498
|
function renderToolsTabHtml(cfg) {
|
|
1477
|
-
return '<section><h3>Local tools (' + cfg.localTools.length + ')</h3>' +
|
|
1478
|
-
'<div class="admin-subsection"><h4>Create an HTTP tool</h4>' + renderHttpToolFormHtml() + '</div>' +
|
|
1499
|
+
return '<section><h3>Local tools (' + cfg.localTools.length + ')</h3>' + renderLocalTools(cfg.localTools) +
|
|
1500
|
+
'<div class="admin-subsection"><h4 id="httpToolFormHeading">Create an HTTP tool</h4>' + renderHttpToolFormHtml() + '</div>' +
|
|
1479
1501
|
'</section>' +
|
|
1480
1502
|
'<section id="gatewayToolsSection">' +
|
|
1481
1503
|
'<h3>Gateway Tools <button type="button" id="gatewayToolsRefreshBtn" class="hint-btn">Refresh</button></h3>' +
|
|
@@ -1521,7 +1543,7 @@ export const agentsConfigPageHtml = `<!doctype html>
|
|
|
1521
1543
|
httpToolHeaderRowCount = 0;
|
|
1522
1544
|
return '<form class="add-source" id="httpToolForm">' +
|
|
1523
1545
|
'<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">' +
|
|
1546
|
+
'<input type="text" name="name" id="httpToolNameInput" required pattern="[a-z][a-z0-9_]*" placeholder="lookup_order_status">' +
|
|
1525
1547
|
'</label>' +
|
|
1526
1548
|
'<label>Description' +
|
|
1527
1549
|
'<textarea name="description" id="httpToolDescriptionInput" required placeholder="What this tool does"></textarea>' +
|
|
@@ -1547,10 +1569,11 @@ export const agentsConfigPageHtml = `<!doctype html>
|
|
|
1547
1569
|
'<button type="button" id="httpToolAddHeaderBtn" class="hint-btn">Add header</button>' +
|
|
1548
1570
|
'</label>' +
|
|
1549
1571
|
'<label class="http-tool-inline-label"><input type="checkbox" name="sendFieldsAsJsonBody"> Send fields as a JSON body (POST/PUT/PATCH only)</label>' +
|
|
1572
|
+
'<label class="http-tool-inline-label"><input type="checkbox" name="safe"> Parallel-safe <span class="hint">(read-only, no side effects — safe to run alongside other calls)</span></label>' +
|
|
1550
1573
|
'<label>Response JSON path <span class="hint">(optional — e.g. data.status; leave blank to return the raw response text)</span>' +
|
|
1551
1574
|
'<input type="text" name="responseJsonPath" placeholder="data.status">' +
|
|
1552
1575
|
'</label>' +
|
|
1553
|
-
'<label>Grant permission <span class="hint">(optional — leave blank to leave this tool at actauth's own default, typically deny)</span>' +
|
|
1576
|
+
'<label id="httpToolDecisionField">Grant permission <span class="hint">(optional — leave blank to leave this tool at actauth's own default, typically deny)</span>' +
|
|
1554
1577
|
'<select name="decision">' +
|
|
1555
1578
|
'<option value="">(leave unset)</option>' +
|
|
1556
1579
|
'<option value="allow">allow</option>' +
|
|
@@ -1559,6 +1582,7 @@ export const agentsConfigPageHtml = `<!doctype html>
|
|
|
1559
1582
|
'</select>' +
|
|
1560
1583
|
'</label>' +
|
|
1561
1584
|
'<button type="submit" id="httpToolSubmitBtn">Create tool</button>' +
|
|
1585
|
+
'<button type="button" id="httpToolCancelBtn" style="display:none">Cancel</button>' +
|
|
1562
1586
|
'<div id="httpToolError" class="error"></div>' +
|
|
1563
1587
|
'</form>';
|
|
1564
1588
|
}
|
|
@@ -1580,6 +1604,15 @@ export const agentsConfigPageHtml = `<!doctype html>
|
|
|
1580
1604
|
var addHeaderBtn = detail.querySelector('#httpToolAddHeaderBtn');
|
|
1581
1605
|
var errorEl = detail.querySelector('#httpToolError');
|
|
1582
1606
|
var submitBtn = detail.querySelector('#httpToolSubmitBtn');
|
|
1607
|
+
var cancelBtn = detail.querySelector('#httpToolCancelBtn');
|
|
1608
|
+
var nameInput = detail.querySelector('#httpToolNameInput');
|
|
1609
|
+
var decisionField = detail.querySelector('#httpToolDecisionField');
|
|
1610
|
+
var headingEl = detail.querySelector('#httpToolFormHeading');
|
|
1611
|
+
// Non-null only while editing an existing tool — the name field is
|
|
1612
|
+
// disabled then (renaming isn't supported, see updateHttpTool's own
|
|
1613
|
+
// doc comment), so the submitted name always has to come from here,
|
|
1614
|
+
// never from the (excluded-from-FormData) disabled input.
|
|
1615
|
+
var editingHttpToolName = null;
|
|
1583
1616
|
|
|
1584
1617
|
addFieldBtn.addEventListener('click', function () {
|
|
1585
1618
|
fieldRows.insertAdjacentHTML('beforeend', renderHttpToolFieldRow(httpToolFieldRowCount++));
|
|
@@ -1590,6 +1623,85 @@ export const agentsConfigPageHtml = `<!doctype html>
|
|
|
1590
1623
|
wireHttpToolRemoveButton(headerRows.lastElementChild);
|
|
1591
1624
|
});
|
|
1592
1625
|
|
|
1626
|
+
// Repopulates the (blank, create-mode-shaped) form from a saved spec
|
|
1627
|
+
// — same field/header row-building addFieldBtn/addHeaderBtn already
|
|
1628
|
+
// do, just filled in immediately instead of left blank for typing.
|
|
1629
|
+
function populateHttpToolForm(spec) {
|
|
1630
|
+
nameInput.value = spec.name;
|
|
1631
|
+
form.querySelector('#httpToolDescriptionInput').value = spec.description;
|
|
1632
|
+
|
|
1633
|
+
fieldRows.innerHTML = '';
|
|
1634
|
+
for (var i = 0; i < spec.fields.length; i++) {
|
|
1635
|
+
var f = spec.fields[i];
|
|
1636
|
+
fieldRows.insertAdjacentHTML('beforeend', renderHttpToolFieldRow(httpToolFieldRowCount++));
|
|
1637
|
+
var row = fieldRows.lastElementChild;
|
|
1638
|
+
row.querySelector('.http-tool-field-name').value = f.name;
|
|
1639
|
+
row.querySelector('.http-tool-field-type').value = f.type;
|
|
1640
|
+
row.querySelector('.http-tool-field-description').value = f.description || '';
|
|
1641
|
+
row.querySelector('.http-tool-field-required').checked = f.required;
|
|
1642
|
+
wireHttpToolRemoveButton(row);
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
form.querySelector('[name="method"]').value = spec.method;
|
|
1646
|
+
form.querySelector('[name="url"]').value = spec.url;
|
|
1647
|
+
|
|
1648
|
+
headerRows.innerHTML = '';
|
|
1649
|
+
for (var j = 0; j < spec.headers.length; j++) {
|
|
1650
|
+
var h = spec.headers[j];
|
|
1651
|
+
headerRows.insertAdjacentHTML('beforeend', renderHttpToolHeaderRow(httpToolHeaderRowCount++));
|
|
1652
|
+
var hrow = headerRows.lastElementChild;
|
|
1653
|
+
hrow.querySelector('.http-tool-header-key').value = h.key;
|
|
1654
|
+
hrow.querySelector('.http-tool-header-value').value = h.value;
|
|
1655
|
+
wireHttpToolRemoveButton(hrow);
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
form.querySelector('[name="sendFieldsAsJsonBody"]').checked = spec.sendFieldsAsJsonBody;
|
|
1659
|
+
form.querySelector('[name="safe"]').checked = spec.safe === true;
|
|
1660
|
+
form.querySelector('[name="responseJsonPath"]').value = spec.responseJsonPath || '';
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
function resetHttpToolForm() {
|
|
1664
|
+
form.reset();
|
|
1665
|
+
fieldRows.innerHTML = '';
|
|
1666
|
+
headerRows.innerHTML = '';
|
|
1667
|
+
nameInput.disabled = false;
|
|
1668
|
+
if (headingEl) headingEl.textContent = 'Create an HTTP tool';
|
|
1669
|
+
submitBtn.textContent = 'Create tool';
|
|
1670
|
+
cancelBtn.style.display = 'none';
|
|
1671
|
+
decisionField.style.display = '';
|
|
1672
|
+
errorEl.textContent = '';
|
|
1673
|
+
editingHttpToolName = null;
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
cancelBtn.addEventListener('click', resetHttpToolForm);
|
|
1677
|
+
|
|
1678
|
+
// Local tools table's own Edit buttons (renderLocalTools) — only
|
|
1679
|
+
// present for tools describeAgent flagged httpToolEditable, so this
|
|
1680
|
+
// GET should never actually 404 in normal use.
|
|
1681
|
+
var editButtons = detail.querySelectorAll('.edit-http-tool-btn');
|
|
1682
|
+
for (var k = 0; k < editButtons.length; k++) {
|
|
1683
|
+
editButtons[k].addEventListener('click', function (ev) {
|
|
1684
|
+
var toolName = ev.currentTarget.getAttribute('data-name');
|
|
1685
|
+
errorEl.textContent = '';
|
|
1686
|
+
fetch('/agents/' + encodeURIComponent(name) + '/tools/http/' + encodeURIComponent(toolName))
|
|
1687
|
+
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
|
|
1688
|
+
.then(function (result) {
|
|
1689
|
+
if (!result.ok) throw new Error(result.body.error || 'request failed');
|
|
1690
|
+
populateHttpToolForm(result.body);
|
|
1691
|
+
nameInput.disabled = true;
|
|
1692
|
+
if (headingEl) headingEl.textContent = 'Edit HTTP tool "' + toolName + '"';
|
|
1693
|
+
submitBtn.textContent = 'Save';
|
|
1694
|
+
cancelBtn.style.display = '';
|
|
1695
|
+
decisionField.style.display = 'none';
|
|
1696
|
+
editingHttpToolName = toolName;
|
|
1697
|
+
form.scrollIntoView({ block: 'nearest' });
|
|
1698
|
+
})
|
|
1699
|
+
.catch(function (err) {
|
|
1700
|
+
errorEl.textContent = 'Could not load tool "' + toolName + '": ' + err.message;
|
|
1701
|
+
});
|
|
1702
|
+
});
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1593
1705
|
form.addEventListener('submit', function (ev) {
|
|
1594
1706
|
ev.preventDefault();
|
|
1595
1707
|
errorEl.textContent = '';
|
|
@@ -1619,20 +1731,24 @@ export const agentsConfigPageHtml = `<!doctype html>
|
|
|
1619
1731
|
|
|
1620
1732
|
var formData = new FormData(form);
|
|
1621
1733
|
var payload = {
|
|
1622
|
-
name: formData.get('name'),
|
|
1734
|
+
name: editingHttpToolName || formData.get('name'),
|
|
1623
1735
|
description: formData.get('description'),
|
|
1624
1736
|
fields: fields,
|
|
1625
1737
|
method: formData.get('method'),
|
|
1626
1738
|
url: formData.get('url'),
|
|
1627
1739
|
headers: headers,
|
|
1628
1740
|
sendFieldsAsJsonBody: formData.get('sendFieldsAsJsonBody') === 'on',
|
|
1741
|
+
safe: formData.get('safe') === 'on',
|
|
1629
1742
|
responseJsonPath: formData.get('responseJsonPath') ? formData.get('responseJsonPath') : undefined,
|
|
1630
1743
|
decision: formData.get('decision') ? formData.get('decision') : undefined,
|
|
1631
1744
|
};
|
|
1632
1745
|
|
|
1746
|
+
var isEdit = !!editingHttpToolName;
|
|
1747
|
+
var url = '/agents/' + encodeURIComponent(name) + '/tools/http' + (isEdit ? '/' + encodeURIComponent(editingHttpToolName) : '');
|
|
1748
|
+
|
|
1633
1749
|
submitBtn.disabled = true;
|
|
1634
|
-
fetch(
|
|
1635
|
-
method: 'POST',
|
|
1750
|
+
fetch(url, {
|
|
1751
|
+
method: isEdit ? 'PUT' : 'POST',
|
|
1636
1752
|
headers: { 'content-type': 'application/json' },
|
|
1637
1753
|
body: JSON.stringify(payload),
|
|
1638
1754
|
})
|
|
@@ -5,6 +5,10 @@ export declare class HttpToolExistsError extends Error {
|
|
|
5
5
|
}
|
|
6
6
|
export declare class HttpToolIndexShapeError extends Error {
|
|
7
7
|
}
|
|
8
|
+
export declare class HttpToolNotFoundError extends Error {
|
|
9
|
+
}
|
|
10
|
+
export declare class HttpToolNotEditableError extends Error {
|
|
11
|
+
}
|
|
8
12
|
export interface HttpToolField {
|
|
9
13
|
name: string;
|
|
10
14
|
type: 'string' | 'number' | 'boolean';
|
|
@@ -23,6 +27,14 @@ export interface HttpToolSpec {
|
|
|
23
27
|
}[];
|
|
24
28
|
sendFieldsAsJsonBody: boolean;
|
|
25
29
|
responseJsonPath?: string;
|
|
30
|
+
/** Same meaning as ToolDefinition.safe (core/agent-config.ts) — true
|
|
31
|
+
* only for a read-only call with no side effects, so ToolLane can run
|
|
32
|
+
* it in its parallel lane. Defaults to false/omitted (unsafe) rather
|
|
33
|
+
* than inferring from `method`, since a GET can still have side
|
|
34
|
+
* effects (e.g. a webhook-triggering endpoint) and the operator who
|
|
35
|
+
* knows the real API is in a better position to say than a guess
|
|
36
|
+
* based on HTTP verb alone. */
|
|
37
|
+
safe?: boolean;
|
|
26
38
|
}
|
|
27
39
|
/** Generates agents/<agentName>/tools/<spec.name>.ts, registers it in
|
|
28
40
|
* that agent's tools/index.ts, then imports the file it just wrote and
|
|
@@ -35,3 +47,26 @@ export declare function createHttpTool(agentName: string, spec: HttpToolSpec): P
|
|
|
35
47
|
path: string;
|
|
36
48
|
tool: ToolDefinition;
|
|
37
49
|
}>;
|
|
50
|
+
/** Reads back the spec a previous createHttpTool/updateHttpTool call
|
|
51
|
+
* persisted, for the Tools tab's Edit form to repopulate itself from —
|
|
52
|
+
* or null if this tool was never created through this module (a
|
|
53
|
+
* hand-written tools/<name>.ts, or one from before this sidecar existed),
|
|
54
|
+
* which is exactly the signal the route handler uses to decide whether
|
|
55
|
+
* to even show an Edit button for it. */
|
|
56
|
+
export declare function readHttpToolSpec(agentName: string, toolName: string): HttpToolSpec | null;
|
|
57
|
+
/** Every tool name in this agent's tools/ dir with a saved spec — i.e.
|
|
58
|
+
* every tool the Tools tab can offer an Edit button for. Cheap (one
|
|
59
|
+
* readdirSync), safe to call on every GET /agents/:name/config. */
|
|
60
|
+
export declare function listEditableHttpToolNames(agentName: string): string[];
|
|
61
|
+
/** Overwrites an existing admin-created tool in place — same generated-
|
|
62
|
+
* code shape createHttpTool produces, just written to the file that's
|
|
63
|
+
* already there instead of a new one, and without touching tools/index.ts
|
|
64
|
+
* (the import/export names are unchanged, since renaming isn't supported
|
|
65
|
+
* here: spec.name must match toolName). Refuses a tool this module didn't
|
|
66
|
+
* create (no sidecar spec) rather than silently clobbering hand-written
|
|
67
|
+
* logic — same "refuse rather than guess" rule addToolToIndex's own doc
|
|
68
|
+
* comment already applies to index.ts. */
|
|
69
|
+
export declare function updateHttpTool(agentName: string, toolName: string, spec: HttpToolSpec): Promise<{
|
|
70
|
+
path: string;
|
|
71
|
+
tool: ToolDefinition;
|
|
72
|
+
}>;
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
// generated file, or echoed back through the admin API, the same
|
|
22
22
|
// "always env-sourced, never a config literal" rule SendEmail/
|
|
23
23
|
// HttpNotifierConfig's own secrets already follow (core/agent-config.ts).
|
|
24
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
24
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
25
25
|
import { join } from 'node:path';
|
|
26
26
|
import { pathToFileURL } from 'node:url';
|
|
27
27
|
import { agentDir } from '../core/gateway-tools.js';
|
|
@@ -31,6 +31,10 @@ export class HttpToolExistsError extends Error {
|
|
|
31
31
|
}
|
|
32
32
|
export class HttpToolIndexShapeError extends Error {
|
|
33
33
|
}
|
|
34
|
+
export class HttpToolNotFoundError extends Error {
|
|
35
|
+
}
|
|
36
|
+
export class HttpToolNotEditableError extends Error {
|
|
37
|
+
}
|
|
34
38
|
const TOOL_NAME_PATTERN = /^[a-z][a-z0-9_]*$/;
|
|
35
39
|
const FIELD_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
36
40
|
const ENV_VAR_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
|
|
@@ -142,9 +146,13 @@ function generateToolCode(spec) {
|
|
|
142
146
|
: ' return await res.text()\n';
|
|
143
147
|
return `// Generated by the Admin UI's HTTP tool builder (Tools tab) — a
|
|
144
148
|
// parameterized HTTP call, not hand-written logic. Edit freely for
|
|
145
|
-
// anything this template can't express
|
|
146
|
-
//
|
|
147
|
-
//
|
|
149
|
+
// anything this template can't express, but note that hand-editing this
|
|
150
|
+
// file directly (rather than through the Tools tab's own Edit button)
|
|
151
|
+
// leaves the sidecar ${spec.name}.http-tool.json out of sync — the next
|
|
152
|
+
// edit through the UI will overwrite your hand-edits with whatever that
|
|
153
|
+
// sidecar still says. The Admin UI only ever regenerates a tool it
|
|
154
|
+
// created itself, never overwrites a file it doesn't recognize (see
|
|
155
|
+
// web/http-tool-admin.ts's own HttpToolExistsError/HttpToolNotEditableError).
|
|
148
156
|
import type { ToolDefinition } from 'loopengine'
|
|
149
157
|
|
|
150
158
|
export const ${toCamelCase(spec.name)}: ToolDefinition = {
|
|
@@ -163,6 +171,7 @@ ${envReads} const res = await fetch(\`${urlInner}\`, {
|
|
|
163
171
|
${headersObj}${bodyLine} })
|
|
164
172
|
if (!res.ok) throw new Error(\`${spec.name}: HTTP \${res.status}\`)
|
|
165
173
|
${responseHandling} },
|
|
174
|
+
safe: ${spec.safe === true},
|
|
166
175
|
}
|
|
167
176
|
`;
|
|
168
177
|
}
|
|
@@ -191,6 +200,15 @@ function addToolToIndex(toolsIndexPath, toolFileName, exportName) {
|
|
|
191
200
|
const withEntry = withImport.replace(/export const tools: ToolDefinition\[\] = \[([^\]]*)\]/, `export const tools: ToolDefinition[] = [${newEntries}]`);
|
|
192
201
|
writeFileSync(toolsIndexPath, withEntry);
|
|
193
202
|
}
|
|
203
|
+
// The spec is persisted alongside the generated .ts file, under the same
|
|
204
|
+
// name — this is what lets the Tools tab's Edit button repopulate the
|
|
205
|
+
// form later without reverse-parsing generated code back into a spec,
|
|
206
|
+
// and (its second job) is how readHttpToolSpec/listEditableHttpToolNames
|
|
207
|
+
// tell an admin-created tool apart from a hand-written one: only a tool
|
|
208
|
+
// this module itself created has one.
|
|
209
|
+
function specSidecarPath(toolsDir, name) {
|
|
210
|
+
return join(toolsDir, `${name}.http-tool.json`);
|
|
211
|
+
}
|
|
194
212
|
/** Generates agents/<agentName>/tools/<spec.name>.ts, registers it in
|
|
195
213
|
* that agent's tools/index.ts, then imports the file it just wrote and
|
|
196
214
|
* returns the real, callable ToolDefinition object — so the route
|
|
@@ -210,6 +228,7 @@ export async function createHttpTool(agentName, spec) {
|
|
|
210
228
|
const code = generateToolCode(spec);
|
|
211
229
|
mkdirSync(toolsDir, { recursive: true });
|
|
212
230
|
writeFileSync(toolPath, code);
|
|
231
|
+
writeFileSync(specSidecarPath(toolsDir, spec.name), JSON.stringify(spec, null, 2));
|
|
213
232
|
const indexPath = join(toolsDir, 'index.ts');
|
|
214
233
|
if (existsSync(indexPath)) {
|
|
215
234
|
addToolToIndex(indexPath, spec.name, toCamelCase(spec.name));
|
|
@@ -221,3 +240,62 @@ export async function createHttpTool(agentName, spec) {
|
|
|
221
240
|
const mod = (await import(pathToFileURL(toolPath).href));
|
|
222
241
|
return { path: toolPath, tool: mod[exportName] };
|
|
223
242
|
}
|
|
243
|
+
/** Reads back the spec a previous createHttpTool/updateHttpTool call
|
|
244
|
+
* persisted, for the Tools tab's Edit form to repopulate itself from —
|
|
245
|
+
* or null if this tool was never created through this module (a
|
|
246
|
+
* hand-written tools/<name>.ts, or one from before this sidecar existed),
|
|
247
|
+
* which is exactly the signal the route handler uses to decide whether
|
|
248
|
+
* to even show an Edit button for it. */
|
|
249
|
+
export function readHttpToolSpec(agentName, toolName) {
|
|
250
|
+
const sidecarPath = specSidecarPath(join(agentDir(agentName), 'tools'), toolName);
|
|
251
|
+
if (!existsSync(sidecarPath))
|
|
252
|
+
return null;
|
|
253
|
+
return JSON.parse(readFileSync(sidecarPath, 'utf8'));
|
|
254
|
+
}
|
|
255
|
+
/** Every tool name in this agent's tools/ dir with a saved spec — i.e.
|
|
256
|
+
* every tool the Tools tab can offer an Edit button for. Cheap (one
|
|
257
|
+
* readdirSync), safe to call on every GET /agents/:name/config. */
|
|
258
|
+
export function listEditableHttpToolNames(agentName) {
|
|
259
|
+
const toolsDir = join(agentDir(agentName), 'tools');
|
|
260
|
+
if (!existsSync(toolsDir))
|
|
261
|
+
return [];
|
|
262
|
+
return readdirSync(toolsDir)
|
|
263
|
+
.filter((f) => f.endsWith('.http-tool.json'))
|
|
264
|
+
.map((f) => f.slice(0, -'.http-tool.json'.length));
|
|
265
|
+
}
|
|
266
|
+
/** Overwrites an existing admin-created tool in place — same generated-
|
|
267
|
+
* code shape createHttpTool produces, just written to the file that's
|
|
268
|
+
* already there instead of a new one, and without touching tools/index.ts
|
|
269
|
+
* (the import/export names are unchanged, since renaming isn't supported
|
|
270
|
+
* here: spec.name must match toolName). Refuses a tool this module didn't
|
|
271
|
+
* create (no sidecar spec) rather than silently clobbering hand-written
|
|
272
|
+
* logic — same "refuse rather than guess" rule addToolToIndex's own doc
|
|
273
|
+
* comment already applies to index.ts. */
|
|
274
|
+
export async function updateHttpTool(agentName, toolName, spec) {
|
|
275
|
+
if (spec.name !== toolName) {
|
|
276
|
+
throw new HttpToolNameError(`A tool's name can't be changed by editing it — "${toolName}" vs "${spec.name}".`);
|
|
277
|
+
}
|
|
278
|
+
if (!TOOL_NAME_PATTERN.test(spec.name)) {
|
|
279
|
+
throw new HttpToolNameError(`Tool name must be lowercase snake_case (e.g. "lookup_order_status") — got "${spec.name}"`);
|
|
280
|
+
}
|
|
281
|
+
const toolsDir = join(agentDir(agentName), 'tools');
|
|
282
|
+
const toolPath = join(toolsDir, `${spec.name}.ts`);
|
|
283
|
+
if (!existsSync(toolPath)) {
|
|
284
|
+
throw new HttpToolNotFoundError(`agents/${agentName}/tools/${spec.name}.ts doesn't exist — nothing to edit.`);
|
|
285
|
+
}
|
|
286
|
+
const sidecarPath = specSidecarPath(toolsDir, spec.name);
|
|
287
|
+
if (!existsSync(sidecarPath)) {
|
|
288
|
+
throw new HttpToolNotEditableError(`agents/${agentName}/tools/${spec.name}.ts wasn't created by this form — edit the file directly instead.`);
|
|
289
|
+
}
|
|
290
|
+
const code = generateToolCode(spec);
|
|
291
|
+
writeFileSync(toolPath, code);
|
|
292
|
+
writeFileSync(sidecarPath, JSON.stringify(spec, null, 2));
|
|
293
|
+
const exportName = toCamelCase(spec.name);
|
|
294
|
+
// Cache-busted: this exact file URL was already imported once before
|
|
295
|
+
// (at creation, or whenever this agent's tools were last resolved), and
|
|
296
|
+
// Node's ESM cache is keyed by the literal URL string — a plain
|
|
297
|
+
// re-import would silently hand back the stale pre-edit module instead
|
|
298
|
+
// of the code just written above.
|
|
299
|
+
const mod = (await import(`${pathToFileURL(toolPath).href}?edited=${Date.now()}`));
|
|
300
|
+
return { path: toolPath, tool: mod[exportName] };
|
|
301
|
+
}
|