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,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
|
+
}
|