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,320 @@
1
+ /**
2
+ * Chat handler — POST /agent-api/chat
3
+ *
4
+ * Authenticates the user, loads preferences + prompt, starts a ReAct loop,
5
+ * and streams events back as SSE.
6
+ *
7
+ * Request:
8
+ * POST /agent-api/chat
9
+ * Header: Authorization: Bearer <userJwt>
10
+ * Body: { message: string, sessionId?: string, agentId?: string }
11
+ *
12
+ * Response: SSE stream of ReactEvent objects
13
+ */
14
+
15
+ import { initSSE } from '../sse.js';
16
+ import { reactLoop } from '../react-engine.js';
17
+ import { readBody, sendJson, loadPromotedTools, extractJwt } from '../http-utils.js';
18
+ import { insertChatAudit } from '../db.js';
19
+
20
+ /**
21
+ * @param {import('http').IncomingMessage} req
22
+ * @param {import('http').ServerResponse} res
23
+ * @param {object} ctx — { auth, promptStore, preferenceStore, conversationStore, db, config, env, agentRegistry, hitlEngine, verifierRunner }
24
+ */
25
+ export async function handleChat(req, res, ctx) {
26
+ const { auth, promptStore, preferenceStore, conversationStore, db, config, env, agentRegistry } = ctx;
27
+ const startTime = Date.now();
28
+ let auditUserId = 'anon';
29
+ let auditSessionId = '';
30
+ let auditAgentId = null;
31
+ let auditModel = null;
32
+ let auditStatusCode = 200;
33
+ let auditErrorMessage = null;
34
+ let auditToolCount = 0;
35
+ let auditHitlTriggered = 0;
36
+ let auditWarningsCount = 0;
37
+
38
+ // 1. Authenticate
39
+ const authResult = auth.authenticate(req);
40
+ if (!authResult.authenticated) {
41
+ if (db) {
42
+ try {
43
+ insertChatAudit(db, {
44
+ session_id: '', user_id: 'anon', route: '/agent-api/chat',
45
+ status_code: 401, duration_ms: Date.now() - startTime,
46
+ error_message: authResult.error ?? 'Unauthorized'
47
+ });
48
+ } catch { /* non-fatal */ }
49
+ }
50
+ sendJson(res, 401, { error: authResult.error ?? 'Unauthorized' });
51
+ return;
52
+ }
53
+ const userId = authResult.userId;
54
+ auditUserId = userId;
55
+ const userJwt = extractJwt(req);
56
+
57
+ // 1b. Rate limiting — applied after auth (per-user)
58
+ if (ctx.rateLimiter) {
59
+ const rlResult = await ctx.rateLimiter.check(userId, '/agent-api/chat');
60
+ if (!rlResult.allowed) {
61
+ res.setHeader?.('Retry-After', String(rlResult.retryAfter ?? 60));
62
+ if (db) {
63
+ try {
64
+ insertChatAudit(db, {
65
+ session_id: '', user_id: userId, route: '/agent-api/chat',
66
+ status_code: 429, duration_ms: Date.now() - startTime,
67
+ error_message: 'Rate limit exceeded'
68
+ });
69
+ } catch { /* non-fatal */ }
70
+ }
71
+ sendJson(res, 429, { error: 'Rate limit exceeded', retryAfter: rlResult.retryAfter });
72
+ return;
73
+ }
74
+ }
75
+
76
+ // 2. Parse body
77
+ let body;
78
+ try {
79
+ body = await readBody(req);
80
+ } catch (err) {
81
+ if (db) {
82
+ try {
83
+ insertChatAudit(db, {
84
+ session_id: '', user_id: userId, route: '/agent-api/chat',
85
+ status_code: 413, duration_ms: Date.now() - startTime,
86
+ error_message: err.message
87
+ });
88
+ } catch { /* non-fatal */ }
89
+ }
90
+ sendJson(res, 413, { error: err.message });
91
+ return;
92
+ }
93
+ if (!body.message) {
94
+ if (db) {
95
+ try {
96
+ insertChatAudit(db, {
97
+ session_id: '', user_id: userId, route: '/agent-api/chat',
98
+ status_code: 400, duration_ms: Date.now() - startTime,
99
+ error_message: 'message is required'
100
+ });
101
+ } catch { /* non-fatal */ }
102
+ }
103
+ sendJson(res, 400, { error: 'message is required' });
104
+ return;
105
+ }
106
+
107
+ // 3. Resolve agent
108
+ const requestedAgentId = body.agentId || null;
109
+ let agent = null;
110
+ if (agentRegistry) {
111
+ agent = agentRegistry.resolveAgent(requestedAgentId);
112
+ if (requestedAgentId && !agent) {
113
+ if (db) {
114
+ try {
115
+ insertChatAudit(db, {
116
+ session_id: '', user_id: userId, route: '/agent-api/chat',
117
+ status_code: 404, duration_ms: Date.now() - startTime,
118
+ error_message: `Agent "${requestedAgentId}" not found or disabled`
119
+ });
120
+ } catch { /* non-fatal */ }
121
+ }
122
+ sendJson(res, 404, { error: `Agent "${requestedAgentId}" not found or disabled` });
123
+ return;
124
+ }
125
+ }
126
+
127
+ // 4. Build agent-scoped config
128
+ const scopedConfig = agentRegistry ? agentRegistry.buildAgentConfig(config, agent) : config;
129
+
130
+ // 5. Resolve user preferences against scoped config
131
+ const effective = await preferenceStore.resolveEffective(userId, scopedConfig, env);
132
+
133
+ // 6. Pre-validate API key before starting SSE
134
+ if (!effective.apiKey) {
135
+ if (db) {
136
+ try {
137
+ insertChatAudit(db, {
138
+ session_id: '', user_id: userId, route: '/agent-api/chat',
139
+ status_code: 500, duration_ms: Date.now() - startTime,
140
+ error_message: `No API key configured for provider "${effective.provider}"`
141
+ });
142
+ } catch { /* non-fatal */ }
143
+ }
144
+ sendJson(res, 500, {
145
+ error: `No API key configured for provider "${effective.provider}". Set the appropriate environment variable.`
146
+ });
147
+ return;
148
+ }
149
+
150
+ // 7. Get system prompt (agent → global → config → fallback)
151
+ const systemPrompt = agentRegistry
152
+ ? await agentRegistry.resolveSystemPrompt(agent, promptStore, scopedConfig)
153
+ : (promptStore.getActivePrompt() || config.systemPrompt || 'You are a helpful assistant.');
154
+
155
+ // 8. Session management
156
+ let sessionId = body.sessionId;
157
+ if (!sessionId) {
158
+ sessionId = conversationStore.createSession();
159
+ }
160
+ auditSessionId = sessionId;
161
+ auditAgentId = agent?.agent_id ?? null;
162
+ auditModel = effective.model;
163
+
164
+ // 9. Load history
165
+ const rawHistory = await conversationStore.getHistory(sessionId);
166
+ const window = scopedConfig.conversation?.window ?? 25;
167
+ const history = rawHistory.slice(-window).map(row => ({
168
+ role: row.role,
169
+ content: row.content
170
+ }));
171
+
172
+ // Add current message to history
173
+ const messages = [...history, { role: 'user', content: body.message }];
174
+
175
+ // Persist user message
176
+ try {
177
+ await conversationStore.persistMessage(sessionId, 'chat', 'user', body.message, agent?.agent_id, userId);
178
+ } catch (err) {
179
+ process.stderr.write(`[chat] Failed to persist user message: ${err.message}\n`);
180
+ }
181
+
182
+ // 10. Load promoted tools (with agent allowlist filtering)
183
+ const allowlist = agent?.tool_allowlist ?? '*';
184
+ const parsedAllowlist = (allowlist !== '*') ? (() => { try { const parsed = JSON.parse(allowlist); return Array.isArray(parsed) ? parsed : []; } catch { return []; } })() : '*';
185
+ const { toolRows, tools } = loadPromotedTools(db, parsedAllowlist);
186
+
187
+ // 11. Start SSE stream
188
+ const sse = initSSE(res);
189
+
190
+ // Send session info (include agentId for client correlation)
191
+ const sessionEvent = { sessionId };
192
+ if (agent) sessionEvent.agentId = agent.agent_id;
193
+ sse.send('session', sessionEvent);
194
+
195
+ // 12. Build per-request hooks
196
+ const { hitlEngine, verifierRunner } = ctx;
197
+ if (verifierRunner) {
198
+ try { await verifierRunner.loadFromDb(db); } catch { /* non-fatal */ }
199
+ }
200
+
201
+ const hooks = {
202
+ shouldPause(toolCall) {
203
+ if (!hitlEngine) return { pause: false };
204
+ let toolSpec = {};
205
+ const row = toolRows.find(r => r.tool_name === toolCall.name);
206
+ if (row?.spec_json) {
207
+ try { toolSpec = JSON.parse(row.spec_json); } catch { /* ignore */ }
208
+ }
209
+ return {
210
+ pause: hitlEngine.shouldPause(effective.hitlLevel, toolSpec),
211
+ message: `Tool "${toolCall.name}" requires confirmation`
212
+ };
213
+ },
214
+ async onAfterToolCall(toolName, args, result) {
215
+ if (!verifierRunner) return { outcome: 'pass' };
216
+ const vResult = await verifierRunner.verify(toolName, args, result);
217
+ if (vResult.outcome !== 'pass') {
218
+ verifierRunner.logResult(sessionId, toolName, vResult);
219
+ }
220
+ return vResult;
221
+ }
222
+ };
223
+
224
+ // 13. Run ReAct loop
225
+ try {
226
+ const gen = reactLoop({
227
+ provider: effective.provider,
228
+ apiKey: effective.apiKey,
229
+ model: effective.model,
230
+ systemPrompt,
231
+ tools,
232
+ messages,
233
+ maxTurns: scopedConfig.maxTurns ?? 10,
234
+ maxTokens: scopedConfig.maxTokens ?? 4096,
235
+ forgeConfig: scopedConfig,
236
+ db,
237
+ userJwt,
238
+ hooks,
239
+ stream: true
240
+ });
241
+
242
+ let assistantText = '';
243
+ for await (const event of gen) {
244
+ // HITL fix: intercept hitl events, persist partial text, persist pause state, attach resumeToken
245
+ if (event.type === 'hitl' && !hitlEngine) {
246
+ auditStatusCode = 500;
247
+ auditErrorMessage = 'HITL triggered but engine not available; cannot pause';
248
+ sse.send('error', { message: 'HITL triggered but engine not available; cannot pause' });
249
+ sse.close();
250
+ return;
251
+ }
252
+ if (event.type === 'hitl' && hitlEngine) {
253
+ auditHitlTriggered = 1;
254
+ if (assistantText) {
255
+ await conversationStore.persistMessage(sessionId, 'chat', 'assistant', assistantText, agent?.agent_id, userId);
256
+ }
257
+ const resumeToken = await hitlEngine.pause({
258
+ sessionId,
259
+ agentId: agent?.agent_id ?? null,
260
+ conversationMessages: event.conversationMessages,
261
+ pendingToolCalls: event.pendingToolCalls,
262
+ turnIndex: event.turnIndex,
263
+ tool: event.tool,
264
+ args: event.args
265
+ });
266
+ sse.send('hitl', {
267
+ type: 'hitl',
268
+ tool: event.tool,
269
+ message: event.message,
270
+ resumeToken
271
+ });
272
+ assistantText = '';
273
+ continue;
274
+ }
275
+
276
+ sse.send(event.type, event);
277
+
278
+ // Track counts for audit log
279
+ if (event.type === 'tool_call') auditToolCount++;
280
+ if (event.type === 'tool_warning') auditWarningsCount++;
281
+
282
+ // Accumulate assistant text for persistence
283
+ if (event.type === 'text_delta') {
284
+ assistantText += event.content;
285
+ }
286
+ if (event.type === 'text') {
287
+ assistantText = event.content; // authoritative overwrite
288
+ }
289
+
290
+ // Persist on completion
291
+ if (event.type === 'done' && assistantText) {
292
+ await conversationStore.persistMessage(sessionId, 'chat', 'assistant', assistantText, agent?.agent_id, userId);
293
+ }
294
+ }
295
+ } catch (err) {
296
+ auditStatusCode = 500;
297
+ auditErrorMessage = err.message;
298
+ sse.send('error', { type: 'error', message: err.message });
299
+ } finally {
300
+ sse.close();
301
+ if (db) {
302
+ try {
303
+ insertChatAudit(db, {
304
+ session_id: auditSessionId,
305
+ user_id: auditUserId,
306
+ agent_id: auditAgentId,
307
+ route: '/agent-api/chat',
308
+ status_code: auditStatusCode,
309
+ duration_ms: Date.now() - startTime,
310
+ model: auditModel,
311
+ message_text: body?.message?.slice(0, 500) ?? null,
312
+ tool_count: auditToolCount,
313
+ hitl_triggered: auditHitlTriggered,
314
+ warnings_count: auditWarningsCount,
315
+ error_message: auditErrorMessage
316
+ });
317
+ } catch { /* audit failure is non-fatal */ }
318
+ }
319
+ }
320
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Conversation management endpoints.
3
+ *
4
+ * GET /agent-api/conversations — list sessions
5
+ * GET /agent-api/conversations/:sessionId — get history
6
+ * DELETE /agent-api/conversations/:sessionId — delete session
7
+ */
8
+
9
+ import { sendJson } from '../http-utils.js';
10
+
11
+ /**
12
+ * @param {import('http').IncomingMessage} req
13
+ * @param {import('http').ServerResponse} res
14
+ * @param {object} ctx — sidecar context
15
+ */
16
+ export async function handleConversations(req, res, ctx) {
17
+ const { auth, conversationStore } = ctx;
18
+
19
+ // Authenticate
20
+ const authResult = auth.authenticate(req);
21
+ if (!authResult.authenticated) {
22
+ sendJson(res, 401, { error: authResult.error ?? 'Unauthorized' });
23
+ return;
24
+ }
25
+
26
+ const userId = authResult.userId;
27
+
28
+ if (!userId) {
29
+ sendJson(res, 401, { error: 'Token has no user identity claim' });
30
+ return;
31
+ }
32
+
33
+ const url = new URL(req.url, 'http://localhost');
34
+ const segments = url.pathname.split('/').filter(Boolean);
35
+ // segments: ['agent-api', 'conversations', sessionId?]
36
+ // Also handle /agent-api/v1/conversations/...
37
+ const convIndex = segments.indexOf('conversations');
38
+ const sessionId = convIndex >= 0 ? segments[convIndex + 1] : undefined;
39
+
40
+ // GET /agent-api/conversations — list sessions (user-scoped)
41
+ if (req.method === 'GET' && !sessionId) {
42
+ try {
43
+ const sessions = await conversationStore.listSessions(userId);
44
+ sendJson(res, 200, { sessions });
45
+ } catch (err) {
46
+ sendJson(res, 500, { error: `Failed to list conversations: ${err.message}` });
47
+ }
48
+ return;
49
+ }
50
+
51
+ // GET /agent-api/conversations/:sessionId — get history (ownership check)
52
+ if (req.method === 'GET' && sessionId) {
53
+ try {
54
+ const ownerUserId = await conversationStore.getSessionUserId(sessionId);
55
+ if (ownerUserId === undefined) {
56
+ sendJson(res, 404, { error: 'Session not found' });
57
+ return;
58
+ }
59
+ if (ownerUserId !== userId) {
60
+ sendJson(res, 403, { error: 'Forbidden' });
61
+ return;
62
+ }
63
+ const messages = await conversationStore.getHistory(sessionId);
64
+ sendJson(res, 200, { sessionId, messages });
65
+ } catch (err) {
66
+ sendJson(res, 500, { error: `Failed to load history: ${err.message}` });
67
+ }
68
+ return;
69
+ }
70
+
71
+ // DELETE /agent-api/conversations/:sessionId — delete session (ownership check)
72
+ if (req.method === 'DELETE' && sessionId) {
73
+ try {
74
+ const sessionUserId = await conversationStore.getSessionUserId(sessionId);
75
+ if (sessionUserId === undefined) {
76
+ sendJson(res, 404, { error: 'Session not found' });
77
+ return;
78
+ }
79
+ if (sessionUserId !== userId) {
80
+ sendJson(res, 403, { error: 'Forbidden' });
81
+ return;
82
+ }
83
+ await conversationStore.deleteSession(sessionId, userId);
84
+ sendJson(res, 200, { deleted: true });
85
+ } catch (err) {
86
+ sendJson(res, 500, { error: `Failed to delete session: ${err.message}` });
87
+ }
88
+ return;
89
+ }
90
+
91
+ sendJson(res, 405, { error: 'Method not allowed' });
92
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * User Preferences API — per-user model + HITL preferences.
3
+ *
4
+ * GET /agent-api/user/preferences — read preferences + effective values
5
+ * PUT /agent-api/user/preferences — update preferences (gated by config)
6
+ */
7
+
8
+ import { readBody, sendJson } from '../http-utils.js';
9
+
10
+ const VALID_HITL_LEVELS = ['autonomous', 'cautious', 'standard', 'paranoid'];
11
+
12
+ /**
13
+ * GET /agent-api/user/preferences
14
+ */
15
+ export async function handleGetPreferences(req, res, ctx) {
16
+ const { auth, preferenceStore, config, env } = ctx;
17
+
18
+ const authResult = auth.authenticate(req);
19
+ if (!authResult.authenticated) {
20
+ sendJson(res, 401, { error: authResult.error ?? 'Unauthorized' });
21
+ return;
22
+ }
23
+
24
+ const userId = authResult.userId;
25
+ const prefs = preferenceStore.getUserPreferences(userId);
26
+ const effective = await preferenceStore.resolveEffective(userId, config, env);
27
+
28
+ sendJson(res, 200, {
29
+ preferences: prefs ?? { model: null, hitlLevel: null },
30
+ effective: { model: effective.model, hitlLevel: effective.hitlLevel, provider: effective.provider },
31
+ permissions: {
32
+ canChangeModel: !!config.allowUserModelSelect,
33
+ canChangeHitl: !!config.allowUserHitlConfig
34
+ },
35
+ options: {
36
+ hitlLevels: VALID_HITL_LEVELS
37
+ }
38
+ });
39
+ }
40
+
41
+ /**
42
+ * PUT /agent-api/user/preferences
43
+ */
44
+ export async function handlePutPreferences(req, res, ctx) {
45
+ const { auth, preferenceStore, config } = ctx;
46
+
47
+ const authResult = auth.authenticate(req);
48
+ if (!authResult.authenticated) {
49
+ sendJson(res, 401, { error: authResult.error ?? 'Unauthorized' });
50
+ return;
51
+ }
52
+
53
+ const userId = authResult.userId;
54
+
55
+ let body;
56
+ try {
57
+ body = await readBody(req);
58
+ } catch {
59
+ return sendJson(res, 413, { error: 'Request body too large' });
60
+ }
61
+
62
+ // Validate and apply model change
63
+ if (body.model !== undefined && !config.allowUserModelSelect) {
64
+ sendJson(res, 403, { error: 'Model selection is not allowed by app configuration' });
65
+ return;
66
+ }
67
+
68
+ // Validate and apply HITL change
69
+ if (body.hitl_level !== undefined) {
70
+ if (!config.allowUserHitlConfig) {
71
+ sendJson(res, 403, { error: 'HITL level configuration is not allowed by app configuration' });
72
+ return;
73
+ }
74
+ if (!VALID_HITL_LEVELS.includes(body.hitl_level)) {
75
+ sendJson(res, 400, { error: `Invalid hitl_level. Must be one of: ${VALID_HITL_LEVELS.join(', ')}` });
76
+ return;
77
+ }
78
+ }
79
+
80
+ const prefs = {};
81
+ if (body.model !== undefined) prefs.model = body.model;
82
+ if (body.hitl_level !== undefined) prefs.hitlLevel = body.hitl_level;
83
+
84
+ preferenceStore.setUserPreferences(userId, prefs);
85
+
86
+ sendJson(res, 200, { ok: true, updated: prefs });
87
+ }
88
+
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Tools list endpoint — GET /agent-api/tools
3
+ *
4
+ * Returns promoted tools, optionally filtered by agent allowlist.
5
+ */
6
+
7
+ import { sendJson, loadPromotedTools } from '../http-utils.js';
8
+
9
+ /**
10
+ * @param {import('http').IncomingMessage} req
11
+ * @param {import('http').ServerResponse} res
12
+ * @param {object} ctx — sidecar context
13
+ */
14
+ export async function handleToolsList(req, res, ctx) {
15
+ const { auth, db, agentRegistry } = ctx;
16
+
17
+ // Authenticate
18
+ const authResult = auth.authenticate(req);
19
+ if (!authResult.authenticated) {
20
+ sendJson(res, 401, { error: authResult.error ?? 'Unauthorized' });
21
+ return;
22
+ }
23
+
24
+ // Optional ?agent=<id> query param
25
+ const url = new URL(req.url, 'http://localhost');
26
+ const agentParam = url.searchParams.get('agent');
27
+
28
+ let allowlist = '*';
29
+ if (agentParam && agentRegistry) {
30
+ const agent = agentRegistry.resolveAgent(agentParam);
31
+ if (!agent) {
32
+ sendJson(res, 404, { error: `Agent "${agentParam}" not found or disabled` });
33
+ return;
34
+ }
35
+ const raw = agent.tool_allowlist ?? '*';
36
+ if (raw !== '*') {
37
+ try {
38
+ const parsed = JSON.parse(raw);
39
+ allowlist = Array.isArray(parsed) ? parsed : '*';
40
+ } catch {
41
+ allowlist = '*';
42
+ }
43
+ }
44
+ }
45
+
46
+ try {
47
+ const { tools } = loadPromotedTools(db, allowlist);
48
+ // Map to { name, description, schema } shape
49
+ const result = tools.map(t => ({
50
+ name: t.name,
51
+ description: t.description ?? '',
52
+ schema: t.inputSchema ?? {}
53
+ }));
54
+ sendJson(res, 200, { tools: result });
55
+ } catch (err) {
56
+ sendJson(res, 500, { error: `Failed to load tools: ${err.message}` });
57
+ }
58
+ }
@@ -0,0 +1,60 @@
1
+ export type HitlLevel = 'autonomous' | 'cautious' | 'standard' | 'paranoid';
2
+
3
+ export interface HitlToolSpec {
4
+ name?: string;
5
+ /** HTTP method used by the tool — drives 'standard' level pause logic. */
6
+ method?: string;
7
+ /** When true, 'cautious' level will pause for this tool. */
8
+ requiresConfirmation?: boolean;
9
+ }
10
+
11
+ export interface HitlEngineOptions {
12
+ /** better-sqlite3 Database instance — SQLite backend. */
13
+ db?: object;
14
+ /** ioredis / node-redis compatible client — Redis backend (recommended for multi-instance). */
15
+ redis?: object;
16
+ /** node-postgres Pool instance — Postgres backend. */
17
+ pgPool?: object;
18
+ /** Pause state TTL in milliseconds. Default: 300000 (5 minutes). */
19
+ ttlMs?: number;
20
+ }
21
+
22
+ export class HitlEngine {
23
+ constructor(opts?: HitlEngineOptions);
24
+
25
+ /**
26
+ * Determine whether a tool call should pause for user confirmation.
27
+ * @param hitlLevel — user's HITL sensitivity level
28
+ * @param toolSpec — tool metadata (method, requiresConfirmation)
29
+ */
30
+ shouldPause(hitlLevel: HitlLevel, toolSpec?: HitlToolSpec): boolean;
31
+
32
+ /**
33
+ * Store paused conversation state and return a one-time resume token.
34
+ * The token expires after `ttlMs` milliseconds.
35
+ */
36
+ pause(state: unknown): Promise<string>;
37
+
38
+ /**
39
+ * Retrieve and consume the paused state for a resume token.
40
+ * Throws if the token has expired or does not exist.
41
+ */
42
+ resume(resumeToken: string): Promise<unknown>;
43
+ }
44
+
45
+ /**
46
+ * Factory — creates a HitlEngine from forge config.
47
+ * Automatically selects Redis > Postgres > SQLite > in-memory based on which
48
+ * clients are provided.
49
+ *
50
+ * @param config — merged forge config (reads `config.hitl.ttlMs` if set)
51
+ * @param db — better-sqlite3 Database instance (SQLite fallback)
52
+ * @param redis — optional Redis client
53
+ * @param pgPool — optional Postgres Pool
54
+ */
55
+ export function makeHitlEngine(
56
+ config: object,
57
+ db: object,
58
+ redis?: object | null,
59
+ pgPool?: object | null
60
+ ): HitlEngine;