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.
Files changed (107) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +209 -0
  3. package/lib/agent-registry.js +170 -0
  4. package/lib/api-client.js +792 -0
  5. package/lib/api-loader.js +260 -0
  6. package/lib/auth.d.ts +25 -0
  7. package/lib/auth.js +158 -0
  8. package/lib/checks/check-adapter.js +172 -0
  9. package/lib/checks/compose.js +42 -0
  10. package/lib/checks/content-match.js +14 -0
  11. package/lib/checks/cost-budget.js +11 -0
  12. package/lib/checks/index.js +18 -0
  13. package/lib/checks/json-valid.js +15 -0
  14. package/lib/checks/latency.js +11 -0
  15. package/lib/checks/length-bounds.js +17 -0
  16. package/lib/checks/negative-match.js +14 -0
  17. package/lib/checks/no-hallucinated-numbers.js +63 -0
  18. package/lib/checks/non-empty.js +34 -0
  19. package/lib/checks/regex-match.js +12 -0
  20. package/lib/checks/run-checks.js +84 -0
  21. package/lib/checks/schema-match.js +26 -0
  22. package/lib/checks/tool-call-count.js +16 -0
  23. package/lib/checks/tool-selection.js +34 -0
  24. package/lib/checks/types.js +45 -0
  25. package/lib/comparison/compare.js +86 -0
  26. package/lib/comparison/format.js +104 -0
  27. package/lib/comparison/index.js +6 -0
  28. package/lib/comparison/statistics.js +59 -0
  29. package/lib/comparison/types.js +41 -0
  30. package/lib/config-schema.js +200 -0
  31. package/lib/config.d.ts +66 -0
  32. package/lib/conversation-store.d.ts +77 -0
  33. package/lib/conversation-store.js +443 -0
  34. package/lib/db.d.ts +6 -0
  35. package/lib/db.js +1112 -0
  36. package/lib/dep-check.js +99 -0
  37. package/lib/drift-background.js +61 -0
  38. package/lib/drift-monitor.js +187 -0
  39. package/lib/eval-runner.js +566 -0
  40. package/lib/fixtures/fixture-store.js +161 -0
  41. package/lib/fixtures/index.js +11 -0
  42. package/lib/forge-engine.js +982 -0
  43. package/lib/forge-eval-generator.js +417 -0
  44. package/lib/forge-file-writer.js +386 -0
  45. package/lib/forge-service-client.js +190 -0
  46. package/lib/forge-service.d.ts +4 -0
  47. package/lib/forge-service.js +655 -0
  48. package/lib/forge-verifier-generator.js +271 -0
  49. package/lib/handlers/admin.js +151 -0
  50. package/lib/handlers/agents.js +229 -0
  51. package/lib/handlers/chat-resume.js +334 -0
  52. package/lib/handlers/chat-sync.js +320 -0
  53. package/lib/handlers/chat.js +320 -0
  54. package/lib/handlers/conversations.js +92 -0
  55. package/lib/handlers/preferences.js +88 -0
  56. package/lib/handlers/tools-list.js +58 -0
  57. package/lib/hitl-engine.d.ts +60 -0
  58. package/lib/hitl-engine.js +261 -0
  59. package/lib/http-utils.js +92 -0
  60. package/lib/index.d.ts +20 -0
  61. package/lib/index.js +141 -0
  62. package/lib/init.js +636 -0
  63. package/lib/manual-entry.js +59 -0
  64. package/lib/mcp-server.js +252 -0
  65. package/lib/output-groups.js +54 -0
  66. package/lib/postgres-store.d.ts +31 -0
  67. package/lib/postgres-store.js +465 -0
  68. package/lib/preference-store.d.ts +47 -0
  69. package/lib/preference-store.js +79 -0
  70. package/lib/prompt-store.d.ts +42 -0
  71. package/lib/prompt-store.js +60 -0
  72. package/lib/rate-limiter.d.ts +30 -0
  73. package/lib/rate-limiter.js +104 -0
  74. package/lib/react-engine.d.ts +110 -0
  75. package/lib/react-engine.js +337 -0
  76. package/lib/runner/cli.js +156 -0
  77. package/lib/runner/cost-estimator.js +71 -0
  78. package/lib/runner/gate.js +46 -0
  79. package/lib/runner/index.js +165 -0
  80. package/lib/sidecar.d.ts +83 -0
  81. package/lib/sidecar.js +161 -0
  82. package/lib/sse.d.ts +15 -0
  83. package/lib/sse.js +30 -0
  84. package/lib/tools-scanner.js +91 -0
  85. package/lib/tui.js +253 -0
  86. package/lib/verifier-report.js +78 -0
  87. package/lib/verifier-runner.js +338 -0
  88. package/lib/verifier-scanner.js +70 -0
  89. package/lib/verifier-worker-pool.js +196 -0
  90. package/lib/views/chat.js +340 -0
  91. package/lib/views/endpoints.js +203 -0
  92. package/lib/views/eval-run.js +206 -0
  93. package/lib/views/forge-agent.js +538 -0
  94. package/lib/views/forge.js +410 -0
  95. package/lib/views/main-menu.js +275 -0
  96. package/lib/views/mediation.js +381 -0
  97. package/lib/views/model-compare.js +430 -0
  98. package/lib/views/model-comparison.js +333 -0
  99. package/lib/views/onboarding.js +470 -0
  100. package/lib/views/performance.js +237 -0
  101. package/lib/views/run-evals.js +205 -0
  102. package/lib/views/settings.js +829 -0
  103. package/lib/views/tools-evals.js +514 -0
  104. package/lib/views/verifier-coverage.js +617 -0
  105. package/lib/workers/verifier-worker.js +52 -0
  106. package/package.json +123 -0
  107. 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
+ }