agent-tool-forge 0.3.0
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/LICENSE +21 -0
- package/README.md +209 -0
- package/lib/agent-registry.js +170 -0
- package/lib/api-client.js +792 -0
- package/lib/api-loader.js +260 -0
- package/lib/auth.d.ts +25 -0
- package/lib/auth.js +158 -0
- package/lib/checks/check-adapter.js +172 -0
- package/lib/checks/compose.js +42 -0
- package/lib/checks/content-match.js +14 -0
- package/lib/checks/cost-budget.js +11 -0
- package/lib/checks/index.js +18 -0
- package/lib/checks/json-valid.js +15 -0
- package/lib/checks/latency.js +11 -0
- package/lib/checks/length-bounds.js +17 -0
- package/lib/checks/negative-match.js +14 -0
- package/lib/checks/no-hallucinated-numbers.js +63 -0
- package/lib/checks/non-empty.js +34 -0
- package/lib/checks/regex-match.js +12 -0
- package/lib/checks/run-checks.js +84 -0
- package/lib/checks/schema-match.js +26 -0
- package/lib/checks/tool-call-count.js +16 -0
- package/lib/checks/tool-selection.js +34 -0
- package/lib/checks/types.js +45 -0
- package/lib/comparison/compare.js +86 -0
- package/lib/comparison/format.js +104 -0
- package/lib/comparison/index.js +6 -0
- package/lib/comparison/statistics.js +59 -0
- package/lib/comparison/types.js +41 -0
- package/lib/config-schema.js +200 -0
- package/lib/config.d.ts +66 -0
- package/lib/conversation-store.d.ts +77 -0
- package/lib/conversation-store.js +443 -0
- package/lib/db.d.ts +6 -0
- package/lib/db.js +1112 -0
- package/lib/dep-check.js +99 -0
- package/lib/drift-background.js +61 -0
- package/lib/drift-monitor.js +187 -0
- package/lib/eval-runner.js +566 -0
- package/lib/fixtures/fixture-store.js +161 -0
- package/lib/fixtures/index.js +11 -0
- package/lib/forge-engine.js +982 -0
- package/lib/forge-eval-generator.js +417 -0
- package/lib/forge-file-writer.js +386 -0
- package/lib/forge-service-client.js +190 -0
- package/lib/forge-service.d.ts +4 -0
- package/lib/forge-service.js +655 -0
- package/lib/forge-verifier-generator.js +271 -0
- package/lib/handlers/admin.js +151 -0
- package/lib/handlers/agents.js +229 -0
- package/lib/handlers/chat-resume.js +334 -0
- package/lib/handlers/chat-sync.js +320 -0
- package/lib/handlers/chat.js +320 -0
- package/lib/handlers/conversations.js +92 -0
- package/lib/handlers/preferences.js +88 -0
- package/lib/handlers/tools-list.js +58 -0
- package/lib/hitl-engine.d.ts +60 -0
- package/lib/hitl-engine.js +261 -0
- package/lib/http-utils.js +92 -0
- package/lib/index.d.ts +20 -0
- package/lib/index.js +141 -0
- package/lib/init.js +636 -0
- package/lib/manual-entry.js +59 -0
- package/lib/mcp-server.js +252 -0
- package/lib/output-groups.js +54 -0
- package/lib/postgres-store.d.ts +31 -0
- package/lib/postgres-store.js +465 -0
- package/lib/preference-store.d.ts +47 -0
- package/lib/preference-store.js +79 -0
- package/lib/prompt-store.d.ts +42 -0
- package/lib/prompt-store.js +60 -0
- package/lib/rate-limiter.d.ts +30 -0
- package/lib/rate-limiter.js +104 -0
- package/lib/react-engine.d.ts +110 -0
- package/lib/react-engine.js +337 -0
- package/lib/runner/cli.js +156 -0
- package/lib/runner/cost-estimator.js +71 -0
- package/lib/runner/gate.js +46 -0
- package/lib/runner/index.js +165 -0
- package/lib/sidecar.d.ts +83 -0
- package/lib/sidecar.js +161 -0
- package/lib/sse.d.ts +15 -0
- package/lib/sse.js +30 -0
- package/lib/tools-scanner.js +91 -0
- package/lib/tui.js +253 -0
- package/lib/verifier-report.js +78 -0
- package/lib/verifier-runner.js +338 -0
- package/lib/verifier-scanner.js +70 -0
- package/lib/verifier-worker-pool.js +196 -0
- package/lib/views/chat.js +340 -0
- package/lib/views/endpoints.js +203 -0
- package/lib/views/eval-run.js +206 -0
- package/lib/views/forge-agent.js +538 -0
- package/lib/views/forge.js +410 -0
- package/lib/views/main-menu.js +275 -0
- package/lib/views/mediation.js +381 -0
- package/lib/views/model-compare.js +430 -0
- package/lib/views/model-comparison.js +333 -0
- package/lib/views/onboarding.js +470 -0
- package/lib/views/performance.js +237 -0
- package/lib/views/run-evals.js +205 -0
- package/lib/views/settings.js +829 -0
- package/lib/views/tools-evals.js +514 -0
- package/lib/views/verifier-coverage.js +617 -0
- package/lib/workers/verifier-worker.js +52 -0
- package/package.json +123 -0
- package/widget/forge-chat.js +789 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forge Verifier Generator — generates verifier stub files via LLM.
|
|
3
|
+
*
|
|
4
|
+
* Does NOT write files — returns content strings and computed paths so the
|
|
5
|
+
* caller (forge.js) can preview and confirm before writing.
|
|
6
|
+
*
|
|
7
|
+
* @module forge-verifier-generator
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { llmTurn } from './api-client.js';
|
|
11
|
+
import { inferOutputGroups, getVerifiersForGroups } from './output-groups.js';
|
|
12
|
+
|
|
13
|
+
// ── Camel-case helper ──────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Convert a snake_case identifier to camelCase.
|
|
17
|
+
* e.g. "source_attribution" → "sourceAttribution"
|
|
18
|
+
*
|
|
19
|
+
* @param {string} name
|
|
20
|
+
* @returns {string}
|
|
21
|
+
*/
|
|
22
|
+
function _camelCase(name) {
|
|
23
|
+
return name.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ── Verifier order helper ──────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Map a verifier name to a default sort-order prefix.
|
|
30
|
+
* Known wildcard verifiers get 'A' prefix; domain-specific get 'B'.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} verifierName
|
|
33
|
+
* @param {number} index - Position in the list (for uniqueness)
|
|
34
|
+
* @returns {string} e.g. 'A-0001'
|
|
35
|
+
*/
|
|
36
|
+
function defaultOrder(verifierName, index) {
|
|
37
|
+
const wildcardVerifiers = new Set(['source_attribution']);
|
|
38
|
+
const prefix = wildcardVerifiers.has(verifierName) ? 'A' : 'B';
|
|
39
|
+
return `${prefix}-${String(index + 1).padStart(4, '0')}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── Prompt builder ─────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build the LLM prompt for a single verifier stub.
|
|
46
|
+
*
|
|
47
|
+
* @param {object} spec - Tool specification
|
|
48
|
+
* @param {string} verifierName - e.g. 'source_attribution'
|
|
49
|
+
* @param {string} orderStr - e.g. 'A-0001'
|
|
50
|
+
* @returns {string}
|
|
51
|
+
*/
|
|
52
|
+
function buildVerifierPrompt(spec, verifierName, orderStr) {
|
|
53
|
+
const camelVerifier = _camelCase(verifierName);
|
|
54
|
+
const camelTool = _camelCase(spec.name);
|
|
55
|
+
|
|
56
|
+
return `Generate a JavaScript ESM verifier stub file for the verifier named '${verifierName}'.
|
|
57
|
+
This verifier applies to the tool: '${spec.name}'
|
|
58
|
+
Tool description: ${spec.description}
|
|
59
|
+
|
|
60
|
+
The verifier must follow this exact shape:
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* ${verifierName} verifier — stub for ${spec.name}.
|
|
64
|
+
* <One-sentence description of what this verifier checks.>
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
export const ${camelVerifier}Verifier = {
|
|
68
|
+
name: '${verifierName}',
|
|
69
|
+
order: '${orderStr}',
|
|
70
|
+
description: '<one-sentence description>',
|
|
71
|
+
|
|
72
|
+
verify(response, _toolCalls) {
|
|
73
|
+
// EXTENSION POINT: implement verification logic here
|
|
74
|
+
// Return { pass: boolean, warnings: string[], flags: string[] }
|
|
75
|
+
return { pass: true, warnings: [], flags: [] };
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
Rules:
|
|
80
|
+
- The file must be a JavaScript ESM module using named export (no default export).
|
|
81
|
+
- The exported const name must be: ${camelVerifier}Verifier
|
|
82
|
+
- The verify() method receives (response, toolCalls) and must return { pass, warnings, flags }.
|
|
83
|
+
- Include a realistic // EXTENSION POINT comment inside verify() showing what the verifier
|
|
84
|
+
would actually check for this tool's output shape (based on the tool description).
|
|
85
|
+
- Do NOT implement real logic — the body after the comment should return the stub { pass: true, warnings: [], flags: [] }.
|
|
86
|
+
- Add a short JSDoc comment at the top of the file.
|
|
87
|
+
- No imports needed unless a standard library (e.g. path, fs) is genuinely required.
|
|
88
|
+
|
|
89
|
+
Respond with ONLY the file content as plain text — no markdown fences, no prose.`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── LLM call with retry ────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Call the LLM to generate a single verifier stub.
|
|
96
|
+
* Retries up to MAX_RETRIES times with corrective nudges.
|
|
97
|
+
*
|
|
98
|
+
* @param {object} opts
|
|
99
|
+
* @param {object} opts.modelConfig - { provider, apiKey, model }
|
|
100
|
+
* @param {string} opts.prompt
|
|
101
|
+
* @param {string} opts.toolName - For error messages
|
|
102
|
+
* @param {string} opts.verifierName - For error messages
|
|
103
|
+
* @param {number} [opts.maxRetries]
|
|
104
|
+
* @returns {Promise<string>} Raw file content
|
|
105
|
+
*/
|
|
106
|
+
async function callLlmForVerifier({ modelConfig, prompt, toolName, verifierName, maxRetries = 2 }) {
|
|
107
|
+
const messages = [{ role: 'user', content: prompt }];
|
|
108
|
+
let lastError;
|
|
109
|
+
|
|
110
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
111
|
+
let responseText;
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
const turn = await llmTurn({
|
|
115
|
+
provider: modelConfig.provider,
|
|
116
|
+
apiKey: modelConfig.apiKey,
|
|
117
|
+
model: modelConfig.model,
|
|
118
|
+
messages,
|
|
119
|
+
maxTokens: 2048,
|
|
120
|
+
timeoutMs: 60_000
|
|
121
|
+
});
|
|
122
|
+
responseText = turn.text;
|
|
123
|
+
} catch (err) {
|
|
124
|
+
throw new Error(
|
|
125
|
+
`LLM API call failed while generating verifier "${verifierName}" for tool "${toolName}": ${err.message}`
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (!responseText || responseText.trim() === '') {
|
|
130
|
+
lastError = new Error(
|
|
131
|
+
`LLM returned an empty response on attempt ${attempt}/${maxRetries}`
|
|
132
|
+
);
|
|
133
|
+
messages.push({ role: 'assistant', content: responseText || '' });
|
|
134
|
+
messages.push({
|
|
135
|
+
role: 'user',
|
|
136
|
+
content:
|
|
137
|
+
'Your response was empty. Please respond with the full JavaScript ESM verifier file content.'
|
|
138
|
+
});
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Minimal validation: the response must contain the export keyword and the camelCase verifier name
|
|
143
|
+
const camelVerifier = _camelCase(verifierName) + 'Verifier';
|
|
144
|
+
if (!responseText.includes('export') || !responseText.includes(camelVerifier)) {
|
|
145
|
+
lastError = new Error(
|
|
146
|
+
`Attempt ${attempt}/${maxRetries}: LLM response does not look like a valid verifier file ` +
|
|
147
|
+
`(missing 'export' or '${camelVerifier}')`
|
|
148
|
+
);
|
|
149
|
+
messages.push({ role: 'assistant', content: responseText });
|
|
150
|
+
messages.push({
|
|
151
|
+
role: 'user',
|
|
152
|
+
content:
|
|
153
|
+
`Your response did not look like a valid ESM verifier file — it must contain an ` +
|
|
154
|
+
`'export const ${_camelCase(verifierName)}Verifier' declaration. ` +
|
|
155
|
+
'Please provide the complete file content with no markdown or prose.'
|
|
156
|
+
});
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Strip markdown fences if the model wrapped the output anyway
|
|
161
|
+
// Try to extract content from a fenced block anywhere in the response
|
|
162
|
+
const fenceMatch = responseText.match(/```(?:javascript|js)?\s*\n([\s\S]*?)\n?\s*```/i);
|
|
163
|
+
const stripped = fenceMatch ? fenceMatch[1].trim() : responseText.trim();
|
|
164
|
+
|
|
165
|
+
return stripped;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
throw new Error(
|
|
169
|
+
`generateVerifiers: failed to obtain valid verifier content for "${verifierName}" ` +
|
|
170
|
+
`(tool "${toolName}") after ${maxRetries} attempts. Last error: ${lastError?.message}`
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── Barrel line builder ────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Build a single ESM named re-export line for the verifier barrel file.
|
|
178
|
+
*
|
|
179
|
+
* @param {string} verifierName - e.g. 'source_attribution'
|
|
180
|
+
* @param {string} toolName - e.g. 'get_weather'
|
|
181
|
+
* @returns {string} e.g. "export { sourceAttributionVerifier } from './get_weather.source_attribution.verifier.js';"
|
|
182
|
+
*/
|
|
183
|
+
function buildBarrelLine(verifierName, toolName) {
|
|
184
|
+
const camelVerifier = _camelCase(verifierName);
|
|
185
|
+
const fileName = `${toolName}.${verifierName}.verifier.js`;
|
|
186
|
+
// Barrel line uses a relative path (relative to the barrel file in the same dir)
|
|
187
|
+
return `export { ${camelVerifier}Verifier } from './${fileName}';`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ── Main export ────────────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Generate verifier stub files via LLM.
|
|
194
|
+
*
|
|
195
|
+
* Uses output-groups.js to infer which verifiers apply to the tool, then
|
|
196
|
+
* prompts the LLM to generate a stub file for each verifier.
|
|
197
|
+
*
|
|
198
|
+
* Does NOT write to disk. Returns file content and computed paths so the
|
|
199
|
+
* caller (forge.js) can preview and confirm before writing.
|
|
200
|
+
*
|
|
201
|
+
* @param {object} opts
|
|
202
|
+
* @param {object} opts.spec - Tool specification
|
|
203
|
+
* @param {string} opts.spec.name - Snake_case tool name
|
|
204
|
+
* @param {string} opts.spec.description - Human-readable description
|
|
205
|
+
* @param {string[]} [opts.spec.tags] - Used by inferOutputGroups
|
|
206
|
+
* @param {object} opts.projectConfig - forge.config.json contents
|
|
207
|
+
* @param {string} opts.projectRoot - Absolute path to project root
|
|
208
|
+
* @param {object} opts.modelConfig - { provider, apiKey, model }
|
|
209
|
+
* @param {string} opts.modelConfig.provider - 'anthropic' | 'openai'
|
|
210
|
+
* @param {string} opts.modelConfig.apiKey
|
|
211
|
+
* @param {string} opts.modelConfig.model
|
|
212
|
+
*
|
|
213
|
+
* @returns {Promise<{
|
|
214
|
+
* verifierFiles: Array<{
|
|
215
|
+
* path: string,
|
|
216
|
+
* content: string,
|
|
217
|
+
* barrelLine: string | null
|
|
218
|
+
* }>
|
|
219
|
+
* }>}
|
|
220
|
+
*
|
|
221
|
+
* @throws {Error} If LLM returns invalid content after retries for any verifier
|
|
222
|
+
*/
|
|
223
|
+
export async function generateVerifiers({
|
|
224
|
+
spec,
|
|
225
|
+
projectConfig,
|
|
226
|
+
projectRoot,
|
|
227
|
+
modelConfig
|
|
228
|
+
}) {
|
|
229
|
+
const verifiersDir = projectConfig?.verification?.verifiersDir || 'example/verifiers';
|
|
230
|
+
|
|
231
|
+
// Resolve absolute verifiers directory for path construction
|
|
232
|
+
const absVerifiersDir = verifiersDir.startsWith('/')
|
|
233
|
+
? verifiersDir
|
|
234
|
+
: `${projectRoot}/${verifiersDir}`;
|
|
235
|
+
|
|
236
|
+
// ── Infer applicable verifiers from output-groups ──────────────────────
|
|
237
|
+
const outputGroups = inferOutputGroups({ name: spec.name, tags: spec.tags, description: spec.description });
|
|
238
|
+
const verifierNames = getVerifiersForGroups(outputGroups);
|
|
239
|
+
|
|
240
|
+
if (verifierNames.length === 0) {
|
|
241
|
+
// No matching verifiers — return empty result rather than failing
|
|
242
|
+
return { verifierFiles: [] };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ── Generate a stub file for each verifier ─────────────────────────────
|
|
246
|
+
const verifierFiles = [];
|
|
247
|
+
const safeName = (spec.name || 'unnamed').replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
248
|
+
|
|
249
|
+
for (let i = 0; i < verifierNames.length; i++) {
|
|
250
|
+
const verifierName = verifierNames[i];
|
|
251
|
+
const orderStr = defaultOrder(verifierName, i);
|
|
252
|
+
const filePath = `${absVerifiersDir}/${safeName}.${verifierName}.verifier.js`;
|
|
253
|
+
const barrelLine = buildBarrelLine(verifierName, safeName);
|
|
254
|
+
|
|
255
|
+
const prompt = buildVerifierPrompt(spec, verifierName, orderStr);
|
|
256
|
+
const content = await callLlmForVerifier({
|
|
257
|
+
modelConfig,
|
|
258
|
+
prompt,
|
|
259
|
+
toolName: spec.name,
|
|
260
|
+
verifierName
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
verifierFiles.push({
|
|
264
|
+
path: filePath,
|
|
265
|
+
content,
|
|
266
|
+
barrelLine
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return { verifierFiles };
|
|
271
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin API — runtime config updates for app-owners.
|
|
3
|
+
*
|
|
4
|
+
* PUT /forge-admin/config/:section — update a config section
|
|
5
|
+
* GET /forge-admin/config — read current effective config
|
|
6
|
+
*
|
|
7
|
+
* Protected by adminKey (Bearer token).
|
|
8
|
+
* Runtime overlay: in-memory Map merged on top of file config.
|
|
9
|
+
* NOT written back to forge.config.json.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readFileSync, writeFileSync, renameSync } from 'fs';
|
|
13
|
+
import { authenticateAdmin } from '../auth.js';
|
|
14
|
+
import { readBody, sendJson } from '../http-utils.js';
|
|
15
|
+
|
|
16
|
+
const VALID_SECTIONS = ['model', 'hitl', 'permissions', 'conversation'];
|
|
17
|
+
|
|
18
|
+
// Runtime overlay — survives across requests but not restarts
|
|
19
|
+
const runtimeOverlay = new Map();
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* PUT /forge-admin/config/:section
|
|
23
|
+
*/
|
|
24
|
+
export async function handleAdminConfig(req, res, ctx) {
|
|
25
|
+
const url = new URL(req.url, 'http://localhost');
|
|
26
|
+
|
|
27
|
+
// Admin auth
|
|
28
|
+
const adminKey = ctx.config.adminKey;
|
|
29
|
+
if (!adminKey) {
|
|
30
|
+
sendJson(res, 503, { error: 'No adminKey configured' });
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const authResult = authenticateAdmin(req, adminKey);
|
|
34
|
+
if (!authResult.authenticated) {
|
|
35
|
+
sendJson(res, 403, { error: authResult.error ?? 'Forbidden' });
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (req.method === 'GET') {
|
|
40
|
+
return handleAdminConfigGet(req, res, ctx);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (req.method === 'PUT') {
|
|
44
|
+
const pathParts = url.pathname.split('/').filter(Boolean);
|
|
45
|
+
// /forge-admin/config/:section → pathParts = ['forge-admin', 'config', section]
|
|
46
|
+
const section = pathParts[2];
|
|
47
|
+
|
|
48
|
+
if (!section || !VALID_SECTIONS.includes(section)) {
|
|
49
|
+
sendJson(res, 400, { error: `Invalid section. Must be one of: ${VALID_SECTIONS.join(', ')}` });
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const body = await readBody(req);
|
|
54
|
+
runtimeOverlay.set(section, body);
|
|
55
|
+
|
|
56
|
+
// Apply overlay to live config
|
|
57
|
+
applyOverlay(ctx.config, section, body);
|
|
58
|
+
|
|
59
|
+
// Persist overlay change to forge.config.json (atomic write)
|
|
60
|
+
if (ctx.configPath) {
|
|
61
|
+
persistOverlay(ctx.configPath, section, body);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
sendJson(res, 200, { ok: true, section, applied: body });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
sendJson(res, 405, { error: 'Method not allowed' });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* GET /forge-admin/config
|
|
73
|
+
*/
|
|
74
|
+
function handleAdminConfigGet(req, res, ctx) {
|
|
75
|
+
const effective = { ...ctx.config };
|
|
76
|
+
// Merge runtime overlay
|
|
77
|
+
for (const [section, values] of runtimeOverlay) {
|
|
78
|
+
applyOverlay(effective, section, values);
|
|
79
|
+
}
|
|
80
|
+
sendJson(res, 200, effective);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function applyOverlay(config, section, values) {
|
|
84
|
+
switch (section) {
|
|
85
|
+
case 'model':
|
|
86
|
+
if (values.defaultModel) config.defaultModel = values.defaultModel;
|
|
87
|
+
break;
|
|
88
|
+
case 'hitl':
|
|
89
|
+
if (values.defaultHitlLevel) config.defaultHitlLevel = values.defaultHitlLevel;
|
|
90
|
+
break;
|
|
91
|
+
case 'permissions':
|
|
92
|
+
if (values.allowUserModelSelect !== undefined) config.allowUserModelSelect = values.allowUserModelSelect;
|
|
93
|
+
if (values.allowUserHitlConfig !== undefined) config.allowUserHitlConfig = values.allowUserHitlConfig;
|
|
94
|
+
break;
|
|
95
|
+
case 'conversation':
|
|
96
|
+
if (values.window) config.conversation = { ...config.conversation, window: values.window };
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Reset runtime overlay — used by tests.
|
|
103
|
+
*/
|
|
104
|
+
export function _resetOverlay() {
|
|
105
|
+
runtimeOverlay.clear();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Atomically persist a section update to forge.config.json.
|
|
110
|
+
* Reads the existing file (or starts from {}), applies the same field
|
|
111
|
+
* mapping as applyOverlay, writes to a .tmp file, then renames atomically.
|
|
112
|
+
* If write fails (permissions, disk full), logs a warning but does NOT throw —
|
|
113
|
+
* the runtime overlay is still active for this session.
|
|
114
|
+
*
|
|
115
|
+
* @param {string} configPath — absolute path to forge.config.json
|
|
116
|
+
* @param {string} section — config section name
|
|
117
|
+
* @param {object} values — values to merge into the section
|
|
118
|
+
*/
|
|
119
|
+
function persistOverlay(configPath, section, values) {
|
|
120
|
+
try {
|
|
121
|
+
let existing = {};
|
|
122
|
+
try {
|
|
123
|
+
existing = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
124
|
+
} catch {
|
|
125
|
+
// File missing or unreadable — start from empty object
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Apply the same field mapping as applyOverlay
|
|
129
|
+
switch (section) {
|
|
130
|
+
case 'model':
|
|
131
|
+
if (values.defaultModel) existing.defaultModel = values.defaultModel;
|
|
132
|
+
break;
|
|
133
|
+
case 'hitl':
|
|
134
|
+
if (values.defaultHitlLevel) existing.defaultHitlLevel = values.defaultHitlLevel;
|
|
135
|
+
break;
|
|
136
|
+
case 'permissions':
|
|
137
|
+
if (values.allowUserModelSelect !== undefined) existing.allowUserModelSelect = values.allowUserModelSelect;
|
|
138
|
+
if (values.allowUserHitlConfig !== undefined) existing.allowUserHitlConfig = values.allowUserHitlConfig;
|
|
139
|
+
break;
|
|
140
|
+
case 'conversation':
|
|
141
|
+
if (values.window) existing.conversation = { ...(existing.conversation ?? {}), window: values.window };
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const tmpPath = configPath + '.tmp';
|
|
146
|
+
writeFileSync(tmpPath, JSON.stringify(existing, null, 2), 'utf8');
|
|
147
|
+
renameSync(tmpPath, configPath);
|
|
148
|
+
} catch (err) {
|
|
149
|
+
process.stderr.write(`[admin] Warning: could not persist config overlay to ${configPath}: ${err.message}\n`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin Agent API — CRUD for multi-agent registry.
|
|
3
|
+
*
|
|
4
|
+
* All routes require adminKey (Bearer token).
|
|
5
|
+
*
|
|
6
|
+
* Routes:
|
|
7
|
+
* GET /forge-admin/agents — list all agents
|
|
8
|
+
* GET /forge-admin/agents/:agentId — get one
|
|
9
|
+
* POST /forge-admin/agents — create
|
|
10
|
+
* PUT /forge-admin/agents/:agentId — update
|
|
11
|
+
* DELETE /forge-admin/agents/:agentId — delete
|
|
12
|
+
* POST /forge-admin/agents/:agentId/set-default — set default
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { authenticateAdmin } from '../auth.js';
|
|
16
|
+
import { readBody, sendJson } from '../http-utils.js';
|
|
17
|
+
|
|
18
|
+
const AGENT_ID_RE = /^[a-z0-9_-]{1,64}$/;
|
|
19
|
+
const VALID_HITL_LEVELS = new Set(['autonomous', 'cautious', 'standard', 'paranoid']);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {import('http').IncomingMessage} req
|
|
23
|
+
* @param {import('http').ServerResponse} res
|
|
24
|
+
* @param {object} ctx — { config, agentRegistry }
|
|
25
|
+
*/
|
|
26
|
+
export async function handleAgents(req, res, ctx) {
|
|
27
|
+
const { config, agentRegistry } = ctx;
|
|
28
|
+
|
|
29
|
+
// Admin auth
|
|
30
|
+
const adminKey = config.adminKey;
|
|
31
|
+
if (!adminKey) {
|
|
32
|
+
sendJson(res, 503, { error: 'No adminKey configured' });
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const authResult = authenticateAdmin(req, adminKey);
|
|
36
|
+
if (!authResult.authenticated) {
|
|
37
|
+
res.setHeader('WWW-Authenticate', 'Bearer');
|
|
38
|
+
sendJson(res, 401, { error: 'Unauthorized' });
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!agentRegistry) {
|
|
43
|
+
sendJson(res, 501, { error: 'Agent registry not initialized' });
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const url = new URL(req.url, 'http://localhost');
|
|
48
|
+
// /forge-admin/agents/:agentId/set-default or /forge-admin/agents/:agentId or /forge-admin/agents
|
|
49
|
+
const pathParts = url.pathname.split('/').filter(Boolean);
|
|
50
|
+
// pathParts: ['forge-admin', 'agents', agentId?, 'set-default'?]
|
|
51
|
+
const agentId = pathParts[2] || null;
|
|
52
|
+
const action = pathParts[3] || null;
|
|
53
|
+
|
|
54
|
+
if (agentId && !AGENT_ID_RE.test(agentId)) {
|
|
55
|
+
sendJson(res, 400, { error: 'Invalid agent ID format' });
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// POST /forge-admin/agents/:agentId/set-default
|
|
60
|
+
if (req.method === 'POST' && agentId && action === 'set-default') {
|
|
61
|
+
const existing = await agentRegistry.getAgent(agentId);
|
|
62
|
+
if (!existing) {
|
|
63
|
+
sendJson(res, 404, { error: `Agent "${agentId}" not found` });
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
await agentRegistry.setDefault(agentId);
|
|
67
|
+
sendJson(res, 200, { ok: true, agentId });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// GET /forge-admin/agents
|
|
72
|
+
if (req.method === 'GET' && !agentId) {
|
|
73
|
+
const agents = await agentRegistry.getAllAgents();
|
|
74
|
+
sendJson(res, 200, { agents });
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// GET /forge-admin/agents/:agentId
|
|
79
|
+
if (req.method === 'GET' && agentId) {
|
|
80
|
+
const agent = await agentRegistry.getAgent(agentId);
|
|
81
|
+
if (!agent) {
|
|
82
|
+
sendJson(res, 404, { error: `Agent "${agentId}" not found` });
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
sendJson(res, 200, agent);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// POST /forge-admin/agents — create
|
|
90
|
+
if (req.method === 'POST' && !agentId) {
|
|
91
|
+
const body = await readBody(req);
|
|
92
|
+
const err = validateAgentBody(body, true);
|
|
93
|
+
if (err) {
|
|
94
|
+
sendJson(res, 400, { error: err });
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Check for duplicates
|
|
99
|
+
if (await agentRegistry.getAgent(body.id)) {
|
|
100
|
+
sendJson(res, 409, { error: `Agent "${body.id}" already exists` });
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
await agentRegistry.upsertAgent(bodyToRow(body));
|
|
105
|
+
sendJson(res, 201, await agentRegistry.getAgent(body.id));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// PUT /forge-admin/agents/:agentId — update
|
|
110
|
+
if (req.method === 'PUT' && agentId) {
|
|
111
|
+
const existing = await agentRegistry.getAgent(agentId);
|
|
112
|
+
if (!existing) {
|
|
113
|
+
sendJson(res, 404, { error: `Agent "${agentId}" not found` });
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const body = await readBody(req);
|
|
118
|
+
const err = validateAgentBody(body, false);
|
|
119
|
+
if (err) {
|
|
120
|
+
sendJson(res, 400, { error: err });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Merge: existing values as base, body overrides. Mark as admin-edited (seeded_from_config=0).
|
|
125
|
+
const row = bodyToRow({ ...rowToBody(existing), ...body, id: agentId, seeded_from_config: 0 });
|
|
126
|
+
await agentRegistry.upsertAgent(row);
|
|
127
|
+
sendJson(res, 200, await agentRegistry.getAgent(agentId));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// DELETE /forge-admin/agents/:agentId
|
|
132
|
+
if (req.method === 'DELETE' && agentId) {
|
|
133
|
+
const existing = await agentRegistry.getAgent(agentId);
|
|
134
|
+
if (!existing) {
|
|
135
|
+
sendJson(res, 404, { error: `Agent "${agentId}" not found` });
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
await agentRegistry.deleteAgent(agentId);
|
|
139
|
+
// If we deleted the default, auto-promote the first remaining enabled agent
|
|
140
|
+
if (existing.is_default) {
|
|
141
|
+
const remaining = (await agentRegistry.getAllAgents()).filter(a => a.enabled);
|
|
142
|
+
if (remaining.length > 0) {
|
|
143
|
+
await agentRegistry.setDefault(remaining[0].agent_id);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
sendJson(res, 200, { ok: true, deleted: agentId });
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
sendJson(res, 405, { error: 'Method not allowed' });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Validate an agent request body.
|
|
155
|
+
* @param {object} body
|
|
156
|
+
* @param {boolean} isCreate — if true, id and displayName are required
|
|
157
|
+
* @returns {string|null} error message or null
|
|
158
|
+
*/
|
|
159
|
+
function validateAgentBody(body, isCreate) {
|
|
160
|
+
if (isCreate) {
|
|
161
|
+
if (!body.id || typeof body.id !== 'string' || !AGENT_ID_RE.test(body.id)) {
|
|
162
|
+
return 'id is required and must match /^[a-z0-9_-]{1,64}$/';
|
|
163
|
+
}
|
|
164
|
+
if (!body.displayName || typeof body.displayName !== 'string') {
|
|
165
|
+
return 'displayName is required';
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (body.defaultHitlLevel !== undefined && !VALID_HITL_LEVELS.has(body.defaultHitlLevel)) {
|
|
169
|
+
return `defaultHitlLevel must be one of: ${[...VALID_HITL_LEVELS].join(', ')}`;
|
|
170
|
+
}
|
|
171
|
+
if (body.toolAllowlist !== undefined && body.toolAllowlist !== '*' && !Array.isArray(body.toolAllowlist)) {
|
|
172
|
+
return 'toolAllowlist must be "*" or an array of tool names';
|
|
173
|
+
}
|
|
174
|
+
if (body.maxTurns !== undefined && (typeof body.maxTurns !== 'number' || body.maxTurns < 1 || !Number.isInteger(body.maxTurns))) {
|
|
175
|
+
return 'maxTurns must be a positive integer';
|
|
176
|
+
}
|
|
177
|
+
if (body.maxTokens !== undefined && (typeof body.maxTokens !== 'number' || body.maxTokens < 1 || !Number.isInteger(body.maxTokens))) {
|
|
178
|
+
return 'maxTokens must be a positive integer';
|
|
179
|
+
}
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Convert API request body to DB row format.
|
|
185
|
+
*/
|
|
186
|
+
function bodyToRow(body) {
|
|
187
|
+
return {
|
|
188
|
+
agent_id: body.id,
|
|
189
|
+
display_name: body.displayName,
|
|
190
|
+
description: body.description ?? null,
|
|
191
|
+
system_prompt: body.systemPrompt ?? null,
|
|
192
|
+
default_model: body.defaultModel ?? null,
|
|
193
|
+
default_hitl_level: body.defaultHitlLevel ?? null,
|
|
194
|
+
allow_user_model_select: body.allowUserModelSelect ? 1 : 0,
|
|
195
|
+
allow_user_hitl_config: body.allowUserHitlConfig ? 1 : 0,
|
|
196
|
+
tool_allowlist: Array.isArray(body.toolAllowlist) ? JSON.stringify(body.toolAllowlist) : (body.toolAllowlist ?? '*'),
|
|
197
|
+
max_turns: body.maxTurns ?? null,
|
|
198
|
+
max_tokens: body.maxTokens ?? null,
|
|
199
|
+
is_default: body.isDefault ? 1 : 0,
|
|
200
|
+
enabled: body.enabled !== undefined ? (body.enabled ? 1 : 0) : 1,
|
|
201
|
+
seeded_from_config: body.seeded_from_config ?? 0,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Convert DB row to API body format (for merge on update).
|
|
207
|
+
*/
|
|
208
|
+
function rowToBody(row) {
|
|
209
|
+
let toolAllowlist = row.tool_allowlist;
|
|
210
|
+
if (toolAllowlist && toolAllowlist !== '*') {
|
|
211
|
+
try { toolAllowlist = JSON.parse(toolAllowlist); } catch { /* keep string */ }
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
id: row.agent_id,
|
|
215
|
+
displayName: row.display_name,
|
|
216
|
+
description: row.description,
|
|
217
|
+
systemPrompt: row.system_prompt,
|
|
218
|
+
defaultModel: row.default_model,
|
|
219
|
+
defaultHitlLevel: row.default_hitl_level,
|
|
220
|
+
allowUserModelSelect: !!row.allow_user_model_select,
|
|
221
|
+
allowUserHitlConfig: !!row.allow_user_hitl_config,
|
|
222
|
+
toolAllowlist,
|
|
223
|
+
maxTurns: row.max_turns,
|
|
224
|
+
maxTokens: row.max_tokens,
|
|
225
|
+
isDefault: !!row.is_default,
|
|
226
|
+
enabled: !!row.enabled,
|
|
227
|
+
seeded_from_config: Boolean(row.seeded_from_config)
|
|
228
|
+
};
|
|
229
|
+
}
|