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,334 @@
1
+ /**
2
+ * Chat Resume handler — POST /agent-api/chat/resume
3
+ *
4
+ * Resumes a paused HITL state. If the user confirms, the ReAct loop
5
+ * continues from where it paused. If rejected, the pause state is discarded.
6
+ *
7
+ * Request:
8
+ * POST /agent-api/chat/resume
9
+ * Header: Authorization: Bearer <userJwt>
10
+ * Body: { resumeToken: string, confirmed: boolean }
11
+ *
12
+ * Response:
13
+ * If confirmed: SSE stream of remaining ReactEvent objects
14
+ * If rejected: JSON { message: "Cancelled" }
15
+ * If expired: 410 Gone
16
+ */
17
+
18
+ import { initSSE } from '../sse.js';
19
+ import { reactLoop } from '../react-engine.js';
20
+ import { readBody, sendJson, loadPromotedTools, extractJwt } from '../http-utils.js';
21
+ import { insertChatAudit } from '../db.js';
22
+
23
+ /**
24
+ * @param {import('http').IncomingMessage} req
25
+ * @param {import('http').ServerResponse} res
26
+ * @param {object} ctx — { auth, hitlEngine, promptStore, preferenceStore, conversationStore, db, config, env, agentRegistry, verifierRunner }
27
+ */
28
+ export async function handleChatResume(req, res, ctx) {
29
+ const { auth, hitlEngine, db } = ctx;
30
+ const startTime = Date.now();
31
+ let auditUserId = 'anon';
32
+ let auditSessionId = null;
33
+ let auditAgentId = null;
34
+ let auditModel = null;
35
+ let auditStatusCode = 200;
36
+ let auditErrorMessage = null;
37
+ let auditToolCount = 0;
38
+ let auditHitlTriggered = 0;
39
+ let auditWarningsCount = 0;
40
+
41
+ // 1. Authenticate
42
+ const authResult = auth.authenticate(req);
43
+ if (!authResult.authenticated) {
44
+ if (db) {
45
+ try {
46
+ insertChatAudit(db, {
47
+ session_id: '', user_id: 'anon', route: '/agent-api/chat/resume',
48
+ status_code: 401, duration_ms: Date.now() - startTime,
49
+ error_message: authResult.error ?? 'Unauthorized'
50
+ });
51
+ } catch { /* non-fatal */ }
52
+ }
53
+ sendJson(res, 401, { error: authResult.error ?? 'Unauthorized' });
54
+ return;
55
+ }
56
+ const userId = authResult.userId;
57
+ auditUserId = userId;
58
+
59
+ // 1b. Rate limiting — applied after auth (per-user)
60
+ if (ctx.rateLimiter) {
61
+ const rlResult = await ctx.rateLimiter.check(authResult.userId, '/agent-api/chat/resume');
62
+ if (!rlResult.allowed) {
63
+ res.setHeader?.('Retry-After', String(rlResult.retryAfter ?? 60));
64
+ if (db) {
65
+ try {
66
+ insertChatAudit(db, {
67
+ session_id: '', user_id: userId, route: '/agent-api/chat/resume',
68
+ status_code: 429, duration_ms: Date.now() - startTime,
69
+ error_message: 'Rate limit exceeded'
70
+ });
71
+ } catch { /* non-fatal */ }
72
+ }
73
+ sendJson(res, 429, { error: 'Rate limit exceeded', retryAfter: rlResult.retryAfter });
74
+ return;
75
+ }
76
+ }
77
+
78
+ // 2. Parse body
79
+ let body;
80
+ try {
81
+ body = await readBody(req);
82
+ } catch (err) {
83
+ if (db) {
84
+ try {
85
+ insertChatAudit(db, {
86
+ session_id: '', user_id: userId, route: '/agent-api/chat/resume',
87
+ status_code: 413, duration_ms: Date.now() - startTime,
88
+ error_message: err.message
89
+ });
90
+ } catch { /* non-fatal */ }
91
+ }
92
+ sendJson(res, 413, { error: err.message });
93
+ return;
94
+ }
95
+ if (!body.resumeToken) {
96
+ if (db) {
97
+ try {
98
+ insertChatAudit(db, {
99
+ session_id: '', user_id: userId, route: '/agent-api/chat/resume',
100
+ status_code: 400, duration_ms: Date.now() - startTime,
101
+ error_message: 'resumeToken is required'
102
+ });
103
+ } catch { /* non-fatal */ }
104
+ }
105
+ sendJson(res, 400, { error: 'resumeToken is required' });
106
+ return;
107
+ }
108
+
109
+ // 3. Check confirmed FIRST — a cancellation needs no engine at all
110
+ if (body.confirmed !== true) {
111
+ // Cancellation returns 200 regardless of token validity — the end state
112
+ // (not resuming) is the same whether the token was valid or expired.
113
+ // Clients that need to distinguish "cancelled" from "token not found"
114
+ // should do a GET /hitl/status check before cancelling.
115
+ if (db) {
116
+ try {
117
+ insertChatAudit(db, {
118
+ session_id: '', user_id: userId, route: '/agent-api/chat/resume',
119
+ status_code: 200, duration_ms: Date.now() - startTime,
120
+ error_message: null
121
+ });
122
+ } catch { /* non-fatal */ }
123
+ }
124
+ sendJson(res, 200, { message: 'Cancelled' });
125
+ return;
126
+ }
127
+
128
+ // 4. Check hitlEngine exists (only needed for actual resume)
129
+ if (!hitlEngine) {
130
+ if (db) {
131
+ try {
132
+ insertChatAudit(db, {
133
+ session_id: '', user_id: userId, route: '/agent-api/chat/resume',
134
+ status_code: 501, duration_ms: Date.now() - startTime,
135
+ error_message: 'HITL engine not available'
136
+ });
137
+ } catch { /* non-fatal */ }
138
+ }
139
+ sendJson(res, 501, { error: 'HITL engine not available' });
140
+ return;
141
+ }
142
+
143
+ // 5. NOW consume the pause state
144
+ const pausedState = await hitlEngine.resume(body.resumeToken);
145
+ if (!pausedState) {
146
+ if (db) {
147
+ try {
148
+ insertChatAudit(db, {
149
+ session_id: '', user_id: userId, route: '/agent-api/chat/resume',
150
+ status_code: 404, duration_ms: Date.now() - startTime,
151
+ error_message: 'Resume token not found or expired'
152
+ });
153
+ } catch { /* non-fatal */ }
154
+ }
155
+ sendJson(res, 404, { error: 'Resume token not found or expired' });
156
+ return;
157
+ }
158
+
159
+ // 6. Recover agent from pause state (graceful degradation if agent gone)
160
+ const { preferenceStore, promptStore, conversationStore, config, env, agentRegistry } = ctx;
161
+ const userJwt = extractJwt(req);
162
+
163
+ auditSessionId = pausedState.sessionId ?? null;
164
+
165
+ let agent = null;
166
+ if (agentRegistry && pausedState.agentId) {
167
+ agent = agentRegistry.resolveAgent(pausedState.agentId);
168
+ // If agent no longer exists/disabled, fall back to base config (graceful degradation)
169
+ }
170
+
171
+ auditAgentId = agent?.agent_id ?? pausedState.agentId ?? null;
172
+
173
+ const scopedConfig = agentRegistry ? agentRegistry.buildAgentConfig(config, agent) : config;
174
+ const effective = await preferenceStore.resolveEffective(userId, scopedConfig, env);
175
+
176
+ auditModel = effective.model;
177
+
178
+ // Pre-validate API key
179
+ if (!effective.apiKey) {
180
+ if (db) {
181
+ try {
182
+ insertChatAudit(db, {
183
+ session_id: auditSessionId ?? '', user_id: userId,
184
+ agent_id: auditAgentId ?? null, route: '/agent-api/chat/resume',
185
+ status_code: 500, duration_ms: Date.now() - startTime,
186
+ model: auditModel ?? null,
187
+ error_message: `No API key configured for provider "${effective.provider}"`
188
+ });
189
+ } catch { /* non-fatal */ }
190
+ }
191
+ sendJson(res, 500, {
192
+ error: `No API key configured for provider "${effective.provider}". Set the appropriate environment variable.`
193
+ });
194
+ return;
195
+ }
196
+
197
+ const systemPrompt = agentRegistry
198
+ ? await agentRegistry.resolveSystemPrompt(agent, promptStore, scopedConfig)
199
+ : (promptStore.getActivePrompt() || config.systemPrompt || 'You are a helpful assistant.');
200
+
201
+ // Load promoted tools with agent allowlist
202
+ const allowlist = agent?.tool_allowlist ?? '*';
203
+ const parsedAllowlist = (allowlist !== '*') ? (() => { try { const parsed = JSON.parse(allowlist); return Array.isArray(parsed) ? parsed : []; } catch { return []; } })() : '*';
204
+ const { toolRows, tools } = loadPromotedTools(db, parsedAllowlist);
205
+
206
+ // Start SSE
207
+ const sse = initSSE(res);
208
+
209
+ // Build per-request hooks
210
+ const { verifierRunner } = ctx;
211
+ if (verifierRunner) {
212
+ try { await verifierRunner.loadFromDb(db); } catch { /* non-fatal */ }
213
+ }
214
+
215
+ const hooks = {
216
+ shouldPause(toolCall) {
217
+ if (!hitlEngine) return { pause: false };
218
+ let toolSpec = {};
219
+ const row = toolRows.find(r => r.tool_name === toolCall.name);
220
+ if (row?.spec_json) {
221
+ try { toolSpec = JSON.parse(row.spec_json); } catch { /* ignore */ }
222
+ }
223
+ return {
224
+ pause: hitlEngine.shouldPause(effective.hitlLevel, toolSpec),
225
+ message: `Tool "${toolCall.name}" requires confirmation`
226
+ };
227
+ },
228
+ async onAfterToolCall(toolName, args, result) {
229
+ if (!verifierRunner) return { outcome: 'pass' };
230
+ const vResult = await verifierRunner.verify(toolName, args, result);
231
+ if (vResult.outcome !== 'pass') {
232
+ verifierRunner.logResult(pausedState.sessionId, toolName, vResult);
233
+ }
234
+ return vResult;
235
+ }
236
+ };
237
+
238
+ try {
239
+ // Continue from paused conversation messages
240
+ const gen = reactLoop({
241
+ provider: effective.provider,
242
+ apiKey: effective.apiKey,
243
+ model: effective.model,
244
+ systemPrompt,
245
+ tools,
246
+ messages: pausedState.conversationMessages ?? [],
247
+ maxTurns: scopedConfig.maxTurns ?? 10,
248
+ maxTokens: scopedConfig.maxTokens ?? 4096,
249
+ forgeConfig: scopedConfig,
250
+ db,
251
+ userJwt,
252
+ hooks,
253
+ stream: true
254
+ });
255
+
256
+ let assistantText = '';
257
+ for await (const event of gen) {
258
+ // Handle nested HITL pauses during resume — persist partial text first
259
+ if (event.type === 'hitl' && !hitlEngine) {
260
+ auditStatusCode = 500;
261
+ auditErrorMessage = 'HITL triggered but engine not available; cannot pause';
262
+ sse.send('error', { message: 'HITL triggered but engine not available; cannot pause' });
263
+ sse.close();
264
+ return;
265
+ }
266
+ if (event.type === 'hitl' && hitlEngine) {
267
+ auditHitlTriggered = 1;
268
+ if (assistantText && pausedState.sessionId) {
269
+ try {
270
+ await conversationStore.persistMessage(pausedState.sessionId, 'chat', 'assistant', assistantText, agent?.agent_id ?? pausedState.agentId, userId);
271
+ } catch (err) {
272
+ process.stderr.write(`[chat-resume] Failed to persist partial assistant message: ${err.message}\n`);
273
+ }
274
+ }
275
+ const resumeToken = await hitlEngine.pause({
276
+ sessionId: pausedState.sessionId,
277
+ agentId: agent?.agent_id ?? pausedState.agentId ?? null,
278
+ conversationMessages: event.conversationMessages,
279
+ pendingToolCalls: event.pendingToolCalls,
280
+ turnIndex: event.turnIndex,
281
+ tool: event.tool,
282
+ args: event.args
283
+ });
284
+ sse.send('hitl', {
285
+ type: 'hitl',
286
+ tool: event.tool,
287
+ message: event.message,
288
+ resumeToken
289
+ });
290
+ assistantText = '';
291
+ continue;
292
+ }
293
+
294
+ sse.send(event.type, event);
295
+
296
+ // Track counts for audit log
297
+ if (event.type === 'tool_call') auditToolCount++;
298
+ if (event.type === 'tool_warning') auditWarningsCount++;
299
+
300
+ if (event.type === 'text_delta') assistantText += event.content;
301
+ if (event.type === 'text') assistantText = event.content;
302
+ if (event.type === 'done' && assistantText && pausedState.sessionId) {
303
+ try {
304
+ await conversationStore.persistMessage(pausedState.sessionId, 'chat', 'assistant', assistantText, agent?.agent_id ?? pausedState.agentId, userId);
305
+ } catch (err) {
306
+ process.stderr.write(`[chat-resume] Failed to persist assistant message: ${err.message}\n`);
307
+ }
308
+ }
309
+ }
310
+ } catch (err) {
311
+ auditStatusCode = 500;
312
+ auditErrorMessage = err.message;
313
+ sse.send('error', { type: 'error', message: err.message });
314
+ } finally {
315
+ sse.close();
316
+ if (db) {
317
+ try {
318
+ insertChatAudit(db, {
319
+ session_id: auditSessionId ?? '',
320
+ user_id: auditUserId,
321
+ agent_id: auditAgentId ?? null,
322
+ route: '/agent-api/chat/resume',
323
+ status_code: auditStatusCode,
324
+ duration_ms: Date.now() - startTime,
325
+ model: auditModel ?? null,
326
+ tool_count: auditToolCount,
327
+ hitl_triggered: auditHitlTriggered,
328
+ warnings_count: auditWarningsCount,
329
+ error_message: auditErrorMessage ?? null
330
+ });
331
+ } catch { /* audit failure is non-fatal */ }
332
+ }
333
+ }
334
+ }
@@ -0,0 +1,320 @@
1
+ /**
2
+ * Chat Sync handler — POST /agent-api/chat-sync
3
+ *
4
+ * Synchronous variant of the chat endpoint. Reuses all shared infrastructure
5
+ * (auth, preferences, prompt, session, history, hooks, reactLoop) but buffers
6
+ * events and returns a single JSON response instead of SSE.
7
+ *
8
+ * Request:
9
+ * POST /agent-api/chat-sync
10
+ * Header: Authorization: Bearer <userJwt> (or ?token=<jwt> query param)
11
+ * Body: { message: string, sessionId?: string, agentId?: string }
12
+ *
13
+ * Response (200):
14
+ * { conversationId, agentId?, message, toolCalls, warnings, flags }
15
+ *
16
+ * Response (409 — HITL pause):
17
+ * { resumeToken, tool, message }
18
+ */
19
+
20
+ import { reactLoop } from '../react-engine.js';
21
+ import { readBody, sendJson, loadPromotedTools, extractJwt } from '../http-utils.js';
22
+ import { insertChatAudit } from '../db.js';
23
+
24
+ /**
25
+ * @param {import('http').IncomingMessage} req
26
+ * @param {import('http').ServerResponse} res
27
+ * @param {object} ctx — { auth, promptStore, preferenceStore, conversationStore, db, config, env, agentRegistry, hitlEngine, verifierRunner }
28
+ */
29
+ export async function handleChatSync(req, res, ctx) {
30
+ const { auth, promptStore, preferenceStore, conversationStore, db, config, env, agentRegistry } = ctx;
31
+ const startTime = Date.now();
32
+
33
+ // 1. Authenticate
34
+ const authResult = auth.authenticate(req);
35
+ if (!authResult.authenticated) {
36
+ if (db) {
37
+ try {
38
+ insertChatAudit(db, {
39
+ session_id: '', user_id: 'anon', route: '/agent-api/chat-sync',
40
+ status_code: 401, duration_ms: Date.now() - startTime,
41
+ error_message: authResult.error ?? 'Unauthorized'
42
+ });
43
+ } catch { /* non-fatal */ }
44
+ }
45
+ sendJson(res, 401, { error: authResult.error ?? 'Unauthorized' });
46
+ return;
47
+ }
48
+ const userId = authResult.userId;
49
+ const userJwt = extractJwt(req);
50
+
51
+ // 1b. Rate limiting — applied after auth (per-user)
52
+ if (ctx.rateLimiter) {
53
+ const rlResult = await ctx.rateLimiter.check(userId, '/agent-api/chat-sync');
54
+ if (!rlResult.allowed) {
55
+ res.setHeader?.('Retry-After', String(rlResult.retryAfter ?? 60));
56
+ if (db) {
57
+ try {
58
+ insertChatAudit(db, {
59
+ session_id: '', user_id: userId, route: '/agent-api/chat-sync',
60
+ status_code: 429, duration_ms: Date.now() - startTime,
61
+ error_message: 'Rate limit exceeded'
62
+ });
63
+ } catch { /* non-fatal */ }
64
+ }
65
+ sendJson(res, 429, { error: 'Rate limit exceeded', retryAfter: rlResult.retryAfter });
66
+ return;
67
+ }
68
+ }
69
+
70
+ // 2. Parse body
71
+ let body;
72
+ try {
73
+ body = await readBody(req);
74
+ } catch (err) {
75
+ if (db) {
76
+ try {
77
+ insertChatAudit(db, {
78
+ session_id: '', user_id: userId, route: '/agent-api/chat-sync',
79
+ status_code: 413, duration_ms: Date.now() - startTime,
80
+ error_message: err.message
81
+ });
82
+ } catch { /* non-fatal */ }
83
+ }
84
+ sendJson(res, 413, { error: err.message });
85
+ return;
86
+ }
87
+ if (!body.message) {
88
+ if (db) {
89
+ try {
90
+ insertChatAudit(db, {
91
+ session_id: '', user_id: userId, route: '/agent-api/chat-sync',
92
+ status_code: 400, duration_ms: Date.now() - startTime,
93
+ error_message: 'message is required'
94
+ });
95
+ } catch { /* non-fatal */ }
96
+ }
97
+ sendJson(res, 400, { error: 'message is required' });
98
+ return;
99
+ }
100
+
101
+ // 3. Resolve agent
102
+ const requestedAgentId = body.agentId || null;
103
+ let agent = null;
104
+ if (agentRegistry) {
105
+ agent = agentRegistry.resolveAgent(requestedAgentId);
106
+ if (requestedAgentId && !agent) {
107
+ if (db) {
108
+ try {
109
+ insertChatAudit(db, {
110
+ session_id: '', user_id: userId, route: '/agent-api/chat-sync',
111
+ status_code: 404, duration_ms: Date.now() - startTime,
112
+ error_message: `Agent "${requestedAgentId}" not found or disabled`
113
+ });
114
+ } catch { /* non-fatal */ }
115
+ }
116
+ sendJson(res, 404, { error: `Agent "${requestedAgentId}" not found or disabled` });
117
+ return;
118
+ }
119
+ }
120
+
121
+ // 4. Build agent-scoped config
122
+ const scopedConfig = agentRegistry ? agentRegistry.buildAgentConfig(config, agent) : config;
123
+
124
+ // 5. Resolve user preferences
125
+ const effective = await preferenceStore.resolveEffective(userId, scopedConfig, env);
126
+
127
+ // 6. Pre-validate API key
128
+ if (!effective.apiKey) {
129
+ if (db) {
130
+ try {
131
+ insertChatAudit(db, {
132
+ session_id: '', user_id: userId, route: '/agent-api/chat-sync',
133
+ status_code: 500, duration_ms: Date.now() - startTime,
134
+ error_message: `No API key configured for provider "${effective.provider}"`
135
+ });
136
+ } catch { /* non-fatal */ }
137
+ }
138
+ sendJson(res, 500, {
139
+ error: `No API key configured for provider "${effective.provider}". Set the appropriate environment variable.`
140
+ });
141
+ return;
142
+ }
143
+
144
+ // 7. Get system prompt
145
+ const systemPrompt = agentRegistry
146
+ ? await agentRegistry.resolveSystemPrompt(agent, promptStore, scopedConfig)
147
+ : (promptStore.getActivePrompt() || config.systemPrompt || 'You are a helpful assistant.');
148
+
149
+ // 7. Session management
150
+ let sessionId = body.sessionId;
151
+ if (!sessionId) {
152
+ sessionId = conversationStore.createSession();
153
+ }
154
+
155
+ // Audit metadata — populated throughout
156
+ const auditMeta = {
157
+ session_id: sessionId,
158
+ user_id: userId,
159
+ agent_id: agent?.agent_id ?? null,
160
+ route: '/agent-api/chat-sync',
161
+ model: effective.model,
162
+ message_text: body.message?.slice(0, 500) ?? null,
163
+ tool_count: 0,
164
+ hitl_triggered: 0,
165
+ warnings_count: 0,
166
+ status_code: 200,
167
+ error_message: null
168
+ };
169
+
170
+ // 8. Load history
171
+ const rawHistory = await conversationStore.getHistory(sessionId);
172
+ const window = scopedConfig.conversation?.window ?? 25;
173
+ const history = rawHistory.slice(-window).map(row => ({
174
+ role: row.role,
175
+ content: row.content
176
+ }));
177
+
178
+ // Add current message to history
179
+ const messages = [...history, { role: 'user', content: body.message }];
180
+
181
+ // Persist user message
182
+ try {
183
+ await conversationStore.persistMessage(sessionId, 'chat', 'user', body.message, agent?.agent_id, userId);
184
+ } catch (err) {
185
+ process.stderr.write(`[chat-sync] Failed to persist user message: ${err.message}\n`);
186
+ }
187
+
188
+ // 10. Load promoted tools (with agent allowlist filtering)
189
+ const allowlist = agent?.tool_allowlist ?? '*';
190
+ const parsedAllowlist = (allowlist !== '*') ? (() => { try { const parsed = JSON.parse(allowlist); return Array.isArray(parsed) ? parsed : []; } catch { return []; } })() : '*';
191
+ const { toolRows, tools } = loadPromotedTools(db, parsedAllowlist);
192
+
193
+ // 10. Build per-request hooks
194
+ const { hitlEngine, verifierRunner } = ctx;
195
+ if (verifierRunner) {
196
+ try { await verifierRunner.loadFromDb(db); } catch { /* non-fatal */ }
197
+ }
198
+
199
+ const hooks = {
200
+ shouldPause(toolCall) {
201
+ if (!hitlEngine) return { pause: false };
202
+ let toolSpec = {};
203
+ const row = toolRows.find(r => r.tool_name === toolCall.name);
204
+ if (row?.spec_json) {
205
+ try { toolSpec = JSON.parse(row.spec_json); } catch { /* ignore */ }
206
+ }
207
+ return {
208
+ pause: hitlEngine.shouldPause(effective.hitlLevel, toolSpec),
209
+ message: `Tool "${toolCall.name}" requires confirmation`
210
+ };
211
+ },
212
+ async onAfterToolCall(toolName, args, result) {
213
+ if (!verifierRunner) return { outcome: 'pass' };
214
+ const vResult = await verifierRunner.verify(toolName, args, result);
215
+ if (vResult.outcome !== 'pass') {
216
+ verifierRunner.logResult(sessionId, toolName, vResult);
217
+ }
218
+ return vResult;
219
+ }
220
+ };
221
+
222
+ // 11. Run ReAct loop and buffer events
223
+ const result = { conversationId: sessionId, message: '', toolCalls: [], warnings: [], flags: [] };
224
+ if (agent) result.agentId = agent.agent_id;
225
+
226
+ try {
227
+ const gen = reactLoop({
228
+ provider: effective.provider,
229
+ apiKey: effective.apiKey,
230
+ model: effective.model,
231
+ systemPrompt,
232
+ tools,
233
+ messages,
234
+ maxTurns: scopedConfig.maxTurns ?? 10,
235
+ maxTokens: scopedConfig.maxTokens ?? 4096,
236
+ forgeConfig: scopedConfig,
237
+ db,
238
+ userJwt,
239
+ hooks
240
+ });
241
+
242
+ for await (const event of gen) {
243
+ switch (event.type) {
244
+ case 'text':
245
+ result.message = event.content;
246
+ break;
247
+ case 'tool_call':
248
+ result.toolCalls.push({ id: event.id, name: event.tool, args: event.args });
249
+ auditMeta.tool_count++;
250
+ break;
251
+ case 'tool_result': {
252
+ const tc = result.toolCalls.find(t => t.id === event.id);
253
+ if (tc) tc.result = event.result;
254
+ break;
255
+ }
256
+ case 'tool_warning':
257
+ result.warnings.push({ tool: event.tool, message: event.message, verifier: event.verifier });
258
+ auditMeta.warnings_count++;
259
+ break;
260
+ case 'hitl': {
261
+ auditMeta.hitl_triggered = 1;
262
+ // Persist any text accumulated before the pause
263
+ if (result.message) {
264
+ await conversationStore.persistMessage(sessionId, 'chat', 'assistant', result.message, agent?.agent_id, userId);
265
+ }
266
+ // Require hitlEngine to persist pause state
267
+ if (!hitlEngine) {
268
+ auditMeta.status_code = 500;
269
+ auditMeta.error_message = 'HITL engine not available; cannot pause';
270
+ return sendJson(res, 500, { error: 'HITL engine not available; cannot pause' });
271
+ }
272
+ let resumeToken;
273
+ try {
274
+ resumeToken = await hitlEngine.pause({
275
+ sessionId,
276
+ agentId: agent?.agent_id ?? null,
277
+ conversationMessages: event.conversationMessages,
278
+ pendingToolCalls: event.pendingToolCalls,
279
+ turnIndex: event.turnIndex,
280
+ tool: event.tool,
281
+ args: event.args
282
+ });
283
+ } catch (pauseErr) {
284
+ auditMeta.status_code = 500;
285
+ auditMeta.error_message = 'Failed to persist HITL state';
286
+ return sendJson(res, 500, { error: 'Failed to persist HITL state' });
287
+ }
288
+ auditMeta.status_code = 409;
289
+ return sendJson(res, 409, { resumeToken, tool: event.tool, message: event.message });
290
+ }
291
+ case 'error':
292
+ result.flags.push(event.message);
293
+ auditMeta.error_message = event.message;
294
+ break;
295
+ case 'done':
296
+ break; // handled after loop
297
+ }
298
+ }
299
+ } catch (err) {
300
+ result.flags.push(err.message);
301
+ auditMeta.status_code = 500;
302
+ auditMeta.error_message = err.message;
303
+ } finally {
304
+ if (db) {
305
+ try {
306
+ insertChatAudit(db, { ...auditMeta, duration_ms: Date.now() - startTime });
307
+ } catch { /* audit failure is non-fatal */ }
308
+ }
309
+ }
310
+
311
+ // Persist assistant message + respond
312
+ if (result.message) {
313
+ try {
314
+ await conversationStore.persistMessage(sessionId, 'chat', 'assistant', result.message, agent?.agent_id, userId);
315
+ } catch (err) {
316
+ process.stderr.write(`[chat-sync] Failed to persist assistant message: ${err.message}\n`);
317
+ }
318
+ }
319
+ sendJson(res, 200, result);
320
+ }