agent-tool-forge 0.3.0 → 0.4.3
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/README.md +8 -3
- package/lib/conversation-store.js +19 -3
- package/lib/db.js +9 -27
- package/lib/eval-runner.js +79 -44
- package/lib/forge-service.js +46 -7
- package/lib/handlers/chat-resume.js +66 -92
- package/lib/handlers/chat-sync.js +42 -60
- package/lib/handlers/chat.js +55 -73
- package/lib/hitl-engine.js +43 -18
- package/lib/postgres-store.js +375 -64
- package/lib/rate-limiter.js +8 -2
- package/lib/sidecar.js +4 -0
- package/lib/verifier-runner.js +31 -10
- package/package.json +3 -3
|
@@ -20,6 +20,16 @@ import { reactLoop } from '../react-engine.js';
|
|
|
20
20
|
import { readBody, sendJson, loadPromotedTools, extractJwt } from '../http-utils.js';
|
|
21
21
|
import { insertChatAudit } from '../db.js';
|
|
22
22
|
|
|
23
|
+
async function auditLog(ctx, row) {
|
|
24
|
+
if (ctx.chatAuditStore) {
|
|
25
|
+
await ctx.chatAuditStore.insertChatAudit(row).catch(() => {});
|
|
26
|
+
} else if (ctx.db) {
|
|
27
|
+
try { insertChatAudit(ctx.db, row); } catch { /* non-fatal */ }
|
|
28
|
+
} else {
|
|
29
|
+
process.stderr.write('[audit] No audit store available — row dropped\n');
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
23
33
|
/**
|
|
24
34
|
* @param {import('http').IncomingMessage} req
|
|
25
35
|
* @param {import('http').ServerResponse} res
|
|
@@ -41,15 +51,11 @@ export async function handleChatResume(req, res, ctx) {
|
|
|
41
51
|
// 1. Authenticate
|
|
42
52
|
const authResult = auth.authenticate(req);
|
|
43
53
|
if (!authResult.authenticated) {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
error_message: authResult.error ?? 'Unauthorized'
|
|
50
|
-
});
|
|
51
|
-
} catch { /* non-fatal */ }
|
|
52
|
-
}
|
|
54
|
+
await auditLog(ctx, {
|
|
55
|
+
session_id: '', user_id: 'anon', route: '/agent-api/chat/resume',
|
|
56
|
+
status_code: 401, duration_ms: Date.now() - startTime,
|
|
57
|
+
error_message: authResult.error ?? 'Unauthorized'
|
|
58
|
+
});
|
|
53
59
|
sendJson(res, 401, { error: authResult.error ?? 'Unauthorized' });
|
|
54
60
|
return;
|
|
55
61
|
}
|
|
@@ -61,15 +67,11 @@ export async function handleChatResume(req, res, ctx) {
|
|
|
61
67
|
const rlResult = await ctx.rateLimiter.check(authResult.userId, '/agent-api/chat/resume');
|
|
62
68
|
if (!rlResult.allowed) {
|
|
63
69
|
res.setHeader?.('Retry-After', String(rlResult.retryAfter ?? 60));
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
error_message: 'Rate limit exceeded'
|
|
70
|
-
});
|
|
71
|
-
} catch { /* non-fatal */ }
|
|
72
|
-
}
|
|
70
|
+
await auditLog(ctx, {
|
|
71
|
+
session_id: '', user_id: userId, route: '/agent-api/chat/resume',
|
|
72
|
+
status_code: 429, duration_ms: Date.now() - startTime,
|
|
73
|
+
error_message: 'Rate limit exceeded'
|
|
74
|
+
});
|
|
73
75
|
sendJson(res, 429, { error: 'Rate limit exceeded', retryAfter: rlResult.retryAfter });
|
|
74
76
|
return;
|
|
75
77
|
}
|
|
@@ -80,28 +82,20 @@ export async function handleChatResume(req, res, ctx) {
|
|
|
80
82
|
try {
|
|
81
83
|
body = await readBody(req);
|
|
82
84
|
} catch (err) {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
error_message: err.message
|
|
89
|
-
});
|
|
90
|
-
} catch { /* non-fatal */ }
|
|
91
|
-
}
|
|
85
|
+
await auditLog(ctx, {
|
|
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
|
+
});
|
|
92
90
|
sendJson(res, 413, { error: err.message });
|
|
93
91
|
return;
|
|
94
92
|
}
|
|
95
93
|
if (!body.resumeToken) {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
error_message: 'resumeToken is required'
|
|
102
|
-
});
|
|
103
|
-
} catch { /* non-fatal */ }
|
|
104
|
-
}
|
|
94
|
+
await auditLog(ctx, {
|
|
95
|
+
session_id: '', user_id: userId, route: '/agent-api/chat/resume',
|
|
96
|
+
status_code: 400, duration_ms: Date.now() - startTime,
|
|
97
|
+
error_message: 'resumeToken is required'
|
|
98
|
+
});
|
|
105
99
|
sendJson(res, 400, { error: 'resumeToken is required' });
|
|
106
100
|
return;
|
|
107
101
|
}
|
|
@@ -112,30 +106,22 @@ export async function handleChatResume(req, res, ctx) {
|
|
|
112
106
|
// (not resuming) is the same whether the token was valid or expired.
|
|
113
107
|
// Clients that need to distinguish "cancelled" from "token not found"
|
|
114
108
|
// should do a GET /hitl/status check before cancelling.
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
error_message: null
|
|
121
|
-
});
|
|
122
|
-
} catch { /* non-fatal */ }
|
|
123
|
-
}
|
|
109
|
+
await auditLog(ctx, {
|
|
110
|
+
session_id: '', user_id: userId, route: '/agent-api/chat/resume',
|
|
111
|
+
status_code: 200, duration_ms: Date.now() - startTime,
|
|
112
|
+
error_message: null
|
|
113
|
+
});
|
|
124
114
|
sendJson(res, 200, { message: 'Cancelled' });
|
|
125
115
|
return;
|
|
126
116
|
}
|
|
127
117
|
|
|
128
118
|
// 4. Check hitlEngine exists (only needed for actual resume)
|
|
129
119
|
if (!hitlEngine) {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
error_message: 'HITL engine not available'
|
|
136
|
-
});
|
|
137
|
-
} catch { /* non-fatal */ }
|
|
138
|
-
}
|
|
120
|
+
await auditLog(ctx, {
|
|
121
|
+
session_id: '', user_id: userId, route: '/agent-api/chat/resume',
|
|
122
|
+
status_code: 501, duration_ms: Date.now() - startTime,
|
|
123
|
+
error_message: 'HITL engine not available'
|
|
124
|
+
});
|
|
139
125
|
sendJson(res, 501, { error: 'HITL engine not available' });
|
|
140
126
|
return;
|
|
141
127
|
}
|
|
@@ -143,15 +129,11 @@ export async function handleChatResume(req, res, ctx) {
|
|
|
143
129
|
// 5. NOW consume the pause state
|
|
144
130
|
const pausedState = await hitlEngine.resume(body.resumeToken);
|
|
145
131
|
if (!pausedState) {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
error_message: 'Resume token not found or expired'
|
|
152
|
-
});
|
|
153
|
-
} catch { /* non-fatal */ }
|
|
154
|
-
}
|
|
132
|
+
await auditLog(ctx, {
|
|
133
|
+
session_id: '', user_id: userId, route: '/agent-api/chat/resume',
|
|
134
|
+
status_code: 404, duration_ms: Date.now() - startTime,
|
|
135
|
+
error_message: 'Resume token not found or expired'
|
|
136
|
+
});
|
|
155
137
|
sendJson(res, 404, { error: 'Resume token not found or expired' });
|
|
156
138
|
return;
|
|
157
139
|
}
|
|
@@ -164,7 +146,7 @@ export async function handleChatResume(req, res, ctx) {
|
|
|
164
146
|
|
|
165
147
|
let agent = null;
|
|
166
148
|
if (agentRegistry && pausedState.agentId) {
|
|
167
|
-
agent = agentRegistry.resolveAgent(pausedState.agentId);
|
|
149
|
+
agent = await agentRegistry.resolveAgent(pausedState.agentId);
|
|
168
150
|
// If agent no longer exists/disabled, fall back to base config (graceful degradation)
|
|
169
151
|
}
|
|
170
152
|
|
|
@@ -177,17 +159,13 @@ export async function handleChatResume(req, res, ctx) {
|
|
|
177
159
|
|
|
178
160
|
// Pre-validate API key
|
|
179
161
|
if (!effective.apiKey) {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
error_message: `No API key configured for provider "${effective.provider}"`
|
|
188
|
-
});
|
|
189
|
-
} catch { /* non-fatal */ }
|
|
190
|
-
}
|
|
162
|
+
await auditLog(ctx, {
|
|
163
|
+
session_id: auditSessionId ?? '', user_id: userId,
|
|
164
|
+
agent_id: auditAgentId ?? null, route: '/agent-api/chat/resume',
|
|
165
|
+
status_code: 500, duration_ms: Date.now() - startTime,
|
|
166
|
+
model: auditModel ?? null,
|
|
167
|
+
error_message: `No API key configured for provider "${effective.provider}"`
|
|
168
|
+
});
|
|
191
169
|
sendJson(res, 500, {
|
|
192
170
|
error: `No API key configured for provider "${effective.provider}". Set the appropriate environment variable.`
|
|
193
171
|
});
|
|
@@ -313,22 +291,18 @@ export async function handleChatResume(req, res, ctx) {
|
|
|
313
291
|
sse.send('error', { type: 'error', message: err.message });
|
|
314
292
|
} finally {
|
|
315
293
|
sse.close();
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
error_message: auditErrorMessage ?? null
|
|
330
|
-
});
|
|
331
|
-
} catch { /* audit failure is non-fatal */ }
|
|
332
|
-
}
|
|
294
|
+
await auditLog(ctx, {
|
|
295
|
+
session_id: auditSessionId ?? '',
|
|
296
|
+
user_id: auditUserId,
|
|
297
|
+
agent_id: auditAgentId ?? null,
|
|
298
|
+
route: '/agent-api/chat/resume',
|
|
299
|
+
status_code: auditStatusCode,
|
|
300
|
+
duration_ms: Date.now() - startTime,
|
|
301
|
+
model: auditModel ?? null,
|
|
302
|
+
tool_count: auditToolCount,
|
|
303
|
+
hitl_triggered: auditHitlTriggered,
|
|
304
|
+
warnings_count: auditWarningsCount,
|
|
305
|
+
error_message: auditErrorMessage ?? null
|
|
306
|
+
});
|
|
333
307
|
}
|
|
334
308
|
}
|
|
@@ -21,6 +21,16 @@ import { reactLoop } from '../react-engine.js';
|
|
|
21
21
|
import { readBody, sendJson, loadPromotedTools, extractJwt } from '../http-utils.js';
|
|
22
22
|
import { insertChatAudit } from '../db.js';
|
|
23
23
|
|
|
24
|
+
async function auditLog(ctx, row) {
|
|
25
|
+
if (ctx.chatAuditStore) {
|
|
26
|
+
await ctx.chatAuditStore.insertChatAudit(row).catch(() => {});
|
|
27
|
+
} else if (ctx.db) {
|
|
28
|
+
try { insertChatAudit(ctx.db, row); } catch { /* non-fatal */ }
|
|
29
|
+
} else {
|
|
30
|
+
process.stderr.write('[audit] No audit store available — row dropped\n');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
24
34
|
/**
|
|
25
35
|
* @param {import('http').IncomingMessage} req
|
|
26
36
|
* @param {import('http').ServerResponse} res
|
|
@@ -33,15 +43,11 @@ export async function handleChatSync(req, res, ctx) {
|
|
|
33
43
|
// 1. Authenticate
|
|
34
44
|
const authResult = auth.authenticate(req);
|
|
35
45
|
if (!authResult.authenticated) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
error_message: authResult.error ?? 'Unauthorized'
|
|
42
|
-
});
|
|
43
|
-
} catch { /* non-fatal */ }
|
|
44
|
-
}
|
|
46
|
+
await auditLog(ctx, {
|
|
47
|
+
session_id: '', user_id: 'anon', route: '/agent-api/chat-sync',
|
|
48
|
+
status_code: 401, duration_ms: Date.now() - startTime,
|
|
49
|
+
error_message: authResult.error ?? 'Unauthorized'
|
|
50
|
+
});
|
|
45
51
|
sendJson(res, 401, { error: authResult.error ?? 'Unauthorized' });
|
|
46
52
|
return;
|
|
47
53
|
}
|
|
@@ -53,15 +59,11 @@ export async function handleChatSync(req, res, ctx) {
|
|
|
53
59
|
const rlResult = await ctx.rateLimiter.check(userId, '/agent-api/chat-sync');
|
|
54
60
|
if (!rlResult.allowed) {
|
|
55
61
|
res.setHeader?.('Retry-After', String(rlResult.retryAfter ?? 60));
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
error_message: 'Rate limit exceeded'
|
|
62
|
-
});
|
|
63
|
-
} catch { /* non-fatal */ }
|
|
64
|
-
}
|
|
62
|
+
await auditLog(ctx, {
|
|
63
|
+
session_id: '', user_id: userId, route: '/agent-api/chat-sync',
|
|
64
|
+
status_code: 429, duration_ms: Date.now() - startTime,
|
|
65
|
+
error_message: 'Rate limit exceeded'
|
|
66
|
+
});
|
|
65
67
|
sendJson(res, 429, { error: 'Rate limit exceeded', retryAfter: rlResult.retryAfter });
|
|
66
68
|
return;
|
|
67
69
|
}
|
|
@@ -72,28 +74,20 @@ export async function handleChatSync(req, res, ctx) {
|
|
|
72
74
|
try {
|
|
73
75
|
body = await readBody(req);
|
|
74
76
|
} catch (err) {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
error_message: err.message
|
|
81
|
-
});
|
|
82
|
-
} catch { /* non-fatal */ }
|
|
83
|
-
}
|
|
77
|
+
await auditLog(ctx, {
|
|
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
|
+
});
|
|
84
82
|
sendJson(res, 413, { error: err.message });
|
|
85
83
|
return;
|
|
86
84
|
}
|
|
87
85
|
if (!body.message) {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
error_message: 'message is required'
|
|
94
|
-
});
|
|
95
|
-
} catch { /* non-fatal */ }
|
|
96
|
-
}
|
|
86
|
+
await auditLog(ctx, {
|
|
87
|
+
session_id: '', user_id: userId, route: '/agent-api/chat-sync',
|
|
88
|
+
status_code: 400, duration_ms: Date.now() - startTime,
|
|
89
|
+
error_message: 'message is required'
|
|
90
|
+
});
|
|
97
91
|
sendJson(res, 400, { error: 'message is required' });
|
|
98
92
|
return;
|
|
99
93
|
}
|
|
@@ -102,17 +96,13 @@ export async function handleChatSync(req, res, ctx) {
|
|
|
102
96
|
const requestedAgentId = body.agentId || null;
|
|
103
97
|
let agent = null;
|
|
104
98
|
if (agentRegistry) {
|
|
105
|
-
agent = agentRegistry.resolveAgent(requestedAgentId);
|
|
99
|
+
agent = await agentRegistry.resolveAgent(requestedAgentId);
|
|
106
100
|
if (requestedAgentId && !agent) {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
error_message: `Agent "${requestedAgentId}" not found or disabled`
|
|
113
|
-
});
|
|
114
|
-
} catch { /* non-fatal */ }
|
|
115
|
-
}
|
|
101
|
+
await auditLog(ctx, {
|
|
102
|
+
session_id: '', user_id: userId, route: '/agent-api/chat-sync',
|
|
103
|
+
status_code: 404, duration_ms: Date.now() - startTime,
|
|
104
|
+
error_message: `Agent "${requestedAgentId}" not found or disabled`
|
|
105
|
+
});
|
|
116
106
|
sendJson(res, 404, { error: `Agent "${requestedAgentId}" not found or disabled` });
|
|
117
107
|
return;
|
|
118
108
|
}
|
|
@@ -126,15 +116,11 @@ export async function handleChatSync(req, res, ctx) {
|
|
|
126
116
|
|
|
127
117
|
// 6. Pre-validate API key
|
|
128
118
|
if (!effective.apiKey) {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
error_message: `No API key configured for provider "${effective.provider}"`
|
|
135
|
-
});
|
|
136
|
-
} catch { /* non-fatal */ }
|
|
137
|
-
}
|
|
119
|
+
await auditLog(ctx, {
|
|
120
|
+
session_id: '', user_id: userId, route: '/agent-api/chat-sync',
|
|
121
|
+
status_code: 500, duration_ms: Date.now() - startTime,
|
|
122
|
+
error_message: `No API key configured for provider "${effective.provider}"`
|
|
123
|
+
});
|
|
138
124
|
sendJson(res, 500, {
|
|
139
125
|
error: `No API key configured for provider "${effective.provider}". Set the appropriate environment variable.`
|
|
140
126
|
});
|
|
@@ -301,11 +287,7 @@ export async function handleChatSync(req, res, ctx) {
|
|
|
301
287
|
auditMeta.status_code = 500;
|
|
302
288
|
auditMeta.error_message = err.message;
|
|
303
289
|
} finally {
|
|
304
|
-
|
|
305
|
-
try {
|
|
306
|
-
insertChatAudit(db, { ...auditMeta, duration_ms: Date.now() - startTime });
|
|
307
|
-
} catch { /* audit failure is non-fatal */ }
|
|
308
|
-
}
|
|
290
|
+
await auditLog(ctx, { ...auditMeta, duration_ms: Date.now() - startTime });
|
|
309
291
|
}
|
|
310
292
|
|
|
311
293
|
// Persist assistant message + respond
|
package/lib/handlers/chat.js
CHANGED
|
@@ -17,6 +17,16 @@ import { reactLoop } from '../react-engine.js';
|
|
|
17
17
|
import { readBody, sendJson, loadPromotedTools, extractJwt } from '../http-utils.js';
|
|
18
18
|
import { insertChatAudit } from '../db.js';
|
|
19
19
|
|
|
20
|
+
async function auditLog(ctx, row) {
|
|
21
|
+
if (ctx.chatAuditStore) {
|
|
22
|
+
await ctx.chatAuditStore.insertChatAudit(row).catch(() => {});
|
|
23
|
+
} else if (ctx.db) {
|
|
24
|
+
try { insertChatAudit(ctx.db, row); } catch { /* non-fatal */ }
|
|
25
|
+
} else {
|
|
26
|
+
process.stderr.write('[audit] No audit store available — row dropped\n');
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
20
30
|
/**
|
|
21
31
|
* @param {import('http').IncomingMessage} req
|
|
22
32
|
* @param {import('http').ServerResponse} res
|
|
@@ -38,15 +48,11 @@ export async function handleChat(req, res, ctx) {
|
|
|
38
48
|
// 1. Authenticate
|
|
39
49
|
const authResult = auth.authenticate(req);
|
|
40
50
|
if (!authResult.authenticated) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
error_message: authResult.error ?? 'Unauthorized'
|
|
47
|
-
});
|
|
48
|
-
} catch { /* non-fatal */ }
|
|
49
|
-
}
|
|
51
|
+
await auditLog(ctx, {
|
|
52
|
+
session_id: '', user_id: 'anon', route: '/agent-api/chat',
|
|
53
|
+
status_code: 401, duration_ms: Date.now() - startTime,
|
|
54
|
+
error_message: authResult.error ?? 'Unauthorized'
|
|
55
|
+
});
|
|
50
56
|
sendJson(res, 401, { error: authResult.error ?? 'Unauthorized' });
|
|
51
57
|
return;
|
|
52
58
|
}
|
|
@@ -59,15 +65,11 @@ export async function handleChat(req, res, ctx) {
|
|
|
59
65
|
const rlResult = await ctx.rateLimiter.check(userId, '/agent-api/chat');
|
|
60
66
|
if (!rlResult.allowed) {
|
|
61
67
|
res.setHeader?.('Retry-After', String(rlResult.retryAfter ?? 60));
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
error_message: 'Rate limit exceeded'
|
|
68
|
-
});
|
|
69
|
-
} catch { /* non-fatal */ }
|
|
70
|
-
}
|
|
68
|
+
await auditLog(ctx, {
|
|
69
|
+
session_id: '', user_id: userId, route: '/agent-api/chat',
|
|
70
|
+
status_code: 429, duration_ms: Date.now() - startTime,
|
|
71
|
+
error_message: 'Rate limit exceeded'
|
|
72
|
+
});
|
|
71
73
|
sendJson(res, 429, { error: 'Rate limit exceeded', retryAfter: rlResult.retryAfter });
|
|
72
74
|
return;
|
|
73
75
|
}
|
|
@@ -78,28 +80,20 @@ export async function handleChat(req, res, ctx) {
|
|
|
78
80
|
try {
|
|
79
81
|
body = await readBody(req);
|
|
80
82
|
} catch (err) {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
error_message: err.message
|
|
87
|
-
});
|
|
88
|
-
} catch { /* non-fatal */ }
|
|
89
|
-
}
|
|
83
|
+
await auditLog(ctx, {
|
|
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
|
+
});
|
|
90
88
|
sendJson(res, 413, { error: err.message });
|
|
91
89
|
return;
|
|
92
90
|
}
|
|
93
91
|
if (!body.message) {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
error_message: 'message is required'
|
|
100
|
-
});
|
|
101
|
-
} catch { /* non-fatal */ }
|
|
102
|
-
}
|
|
92
|
+
await auditLog(ctx, {
|
|
93
|
+
session_id: '', user_id: userId, route: '/agent-api/chat',
|
|
94
|
+
status_code: 400, duration_ms: Date.now() - startTime,
|
|
95
|
+
error_message: 'message is required'
|
|
96
|
+
});
|
|
103
97
|
sendJson(res, 400, { error: 'message is required' });
|
|
104
98
|
return;
|
|
105
99
|
}
|
|
@@ -108,17 +102,13 @@ export async function handleChat(req, res, ctx) {
|
|
|
108
102
|
const requestedAgentId = body.agentId || null;
|
|
109
103
|
let agent = null;
|
|
110
104
|
if (agentRegistry) {
|
|
111
|
-
agent = agentRegistry.resolveAgent(requestedAgentId);
|
|
105
|
+
agent = await agentRegistry.resolveAgent(requestedAgentId);
|
|
112
106
|
if (requestedAgentId && !agent) {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
error_message: `Agent "${requestedAgentId}" not found or disabled`
|
|
119
|
-
});
|
|
120
|
-
} catch { /* non-fatal */ }
|
|
121
|
-
}
|
|
107
|
+
await auditLog(ctx, {
|
|
108
|
+
session_id: '', user_id: userId, route: '/agent-api/chat',
|
|
109
|
+
status_code: 404, duration_ms: Date.now() - startTime,
|
|
110
|
+
error_message: `Agent "${requestedAgentId}" not found or disabled`
|
|
111
|
+
});
|
|
122
112
|
sendJson(res, 404, { error: `Agent "${requestedAgentId}" not found or disabled` });
|
|
123
113
|
return;
|
|
124
114
|
}
|
|
@@ -132,15 +122,11 @@ export async function handleChat(req, res, ctx) {
|
|
|
132
122
|
|
|
133
123
|
// 6. Pre-validate API key before starting SSE
|
|
134
124
|
if (!effective.apiKey) {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
error_message: `No API key configured for provider "${effective.provider}"`
|
|
141
|
-
});
|
|
142
|
-
} catch { /* non-fatal */ }
|
|
143
|
-
}
|
|
125
|
+
await auditLog(ctx, {
|
|
126
|
+
session_id: '', user_id: userId, route: '/agent-api/chat',
|
|
127
|
+
status_code: 500, duration_ms: Date.now() - startTime,
|
|
128
|
+
error_message: `No API key configured for provider "${effective.provider}"`
|
|
129
|
+
});
|
|
144
130
|
sendJson(res, 500, {
|
|
145
131
|
error: `No API key configured for provider "${effective.provider}". Set the appropriate environment variable.`
|
|
146
132
|
});
|
|
@@ -298,23 +284,19 @@ export async function handleChat(req, res, ctx) {
|
|
|
298
284
|
sse.send('error', { type: 'error', message: err.message });
|
|
299
285
|
} finally {
|
|
300
286
|
sse.close();
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
error_message: auditErrorMessage
|
|
316
|
-
});
|
|
317
|
-
} catch { /* audit failure is non-fatal */ }
|
|
318
|
-
}
|
|
287
|
+
await auditLog(ctx, {
|
|
288
|
+
session_id: auditSessionId,
|
|
289
|
+
user_id: auditUserId,
|
|
290
|
+
agent_id: auditAgentId,
|
|
291
|
+
route: '/agent-api/chat',
|
|
292
|
+
status_code: auditStatusCode,
|
|
293
|
+
duration_ms: Date.now() - startTime,
|
|
294
|
+
model: auditModel,
|
|
295
|
+
message_text: body?.message?.slice(0, 500) ?? null,
|
|
296
|
+
tool_count: auditToolCount,
|
|
297
|
+
hitl_triggered: auditHitlTriggered,
|
|
298
|
+
warnings_count: auditWarningsCount,
|
|
299
|
+
error_message: auditErrorMessage
|
|
300
|
+
});
|
|
319
301
|
}
|
|
320
302
|
}
|
package/lib/hitl-engine.js
CHANGED
|
@@ -76,20 +76,55 @@ export class HitlEngine {
|
|
|
76
76
|
|
|
77
77
|
// Postgres table creation is deferred to first use (_ensurePgTable)
|
|
78
78
|
this._pgTableReady = false;
|
|
79
|
+
this._pgTableEnsurePromise = null;
|
|
80
|
+
this._pgCleanupTimer = null;
|
|
79
81
|
}
|
|
80
82
|
|
|
81
83
|
/** @private */
|
|
82
|
-
|
|
83
|
-
if (this._pgTableReady) return;
|
|
84
|
-
|
|
84
|
+
_ensurePgTable() {
|
|
85
|
+
if (this._pgTableReady) return Promise.resolve();
|
|
86
|
+
if (this._pgTableEnsurePromise) return this._pgTableEnsurePromise;
|
|
87
|
+
this._pgTableEnsurePromise = this._pgPool.query(`
|
|
85
88
|
CREATE TABLE IF NOT EXISTS hitl_pending (
|
|
86
89
|
resume_token TEXT PRIMARY KEY,
|
|
87
90
|
state_json TEXT NOT NULL,
|
|
88
91
|
expires_at TEXT NOT NULL,
|
|
89
92
|
created_at TEXT NOT NULL
|
|
90
93
|
)
|
|
91
|
-
`)
|
|
92
|
-
|
|
94
|
+
`).then(() => {
|
|
95
|
+
this._pgTableReady = true;
|
|
96
|
+
// Cleanup expired Postgres HITL rows every 5 minutes
|
|
97
|
+
this._pgCleanupTimer = setInterval(async () => {
|
|
98
|
+
try {
|
|
99
|
+
await this._pgPool.query(
|
|
100
|
+
'DELETE FROM hitl_pending WHERE expires_at < $1',
|
|
101
|
+
[new Date().toISOString()]
|
|
102
|
+
);
|
|
103
|
+
} catch { /* non-fatal */ }
|
|
104
|
+
}, 5 * 60 * 1000);
|
|
105
|
+
this._pgCleanupTimer.unref();
|
|
106
|
+
});
|
|
107
|
+
return this._pgTableEnsurePromise;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Release background timers and pooled resources held by this engine.
|
|
112
|
+
* Call this when the engine is no longer needed to avoid keeping the
|
|
113
|
+
* Node.js event loop alive.
|
|
114
|
+
*/
|
|
115
|
+
destroy() {
|
|
116
|
+
if (this._cleanupTimer) {
|
|
117
|
+
clearInterval(this._cleanupTimer);
|
|
118
|
+
this._cleanupTimer = null;
|
|
119
|
+
}
|
|
120
|
+
if (this._sqliteCleanupTimer) {
|
|
121
|
+
clearInterval(this._sqliteCleanupTimer);
|
|
122
|
+
this._sqliteCleanupTimer = null;
|
|
123
|
+
}
|
|
124
|
+
if (this._pgCleanupTimer) {
|
|
125
|
+
clearInterval(this._pgCleanupTimer);
|
|
126
|
+
this._pgCleanupTimer = null;
|
|
127
|
+
}
|
|
93
128
|
}
|
|
94
129
|
|
|
95
130
|
/**
|
|
@@ -172,11 +207,8 @@ export class HitlEngine {
|
|
|
172
207
|
async resume(resumeToken) {
|
|
173
208
|
if (this._redis) {
|
|
174
209
|
const key = REDIS_KEY_PREFIX + resumeToken;
|
|
175
|
-
|
|
176
|
-
// For simplicity, GET then DEL — race window is acceptable for HITL
|
|
177
|
-
const stateJson = await this._redis.get(key);
|
|
210
|
+
const stateJson = await this._redis.getDel(key);
|
|
178
211
|
if (!stateJson) return null;
|
|
179
|
-
await this._redis.del(key);
|
|
180
212
|
try {
|
|
181
213
|
return JSON.parse(stateJson);
|
|
182
214
|
} catch {
|
|
@@ -187,19 +219,12 @@ export class HitlEngine {
|
|
|
187
219
|
if (this._pgPool) {
|
|
188
220
|
await this._ensurePgTable();
|
|
189
221
|
const { rows } = await this._pgPool.query(
|
|
190
|
-
'
|
|
222
|
+
'DELETE FROM hitl_pending WHERE resume_token = $1 RETURNING *',
|
|
191
223
|
[resumeToken]
|
|
192
224
|
);
|
|
193
225
|
if (rows.length === 0) return null;
|
|
194
|
-
|
|
195
226
|
const row = rows[0];
|
|
196
|
-
//
|
|
197
|
-
await this._pgPool.query(
|
|
198
|
-
'DELETE FROM hitl_pending WHERE resume_token = $1',
|
|
199
|
-
[resumeToken]
|
|
200
|
-
);
|
|
201
|
-
|
|
202
|
-
// Check expiry
|
|
227
|
+
// Check expiry AFTER the atomic delete (expired token is gone either way)
|
|
203
228
|
if (new Date(row.expires_at) < new Date()) return null;
|
|
204
229
|
|
|
205
230
|
try {
|