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.
@@ -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
- 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
- }
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
- 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
- }
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
- 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
- }
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
- 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
- }
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
- 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
- }
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
- 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
- }
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
- 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
- }
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
- 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
- }
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
- 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
- }
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
- 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
- }
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
- 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
- }
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
- 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
- }
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
- 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
- }
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
- 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
- }
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
- 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
- }
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
- if (db) {
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
@@ -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
- 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
- }
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
- 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
- }
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
- 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
- }
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
- 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
- }
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
- 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
- }
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
- 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
- }
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
- 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
- }
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
  }
@@ -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
- async _ensurePgTable() {
83
- if (this._pgTableReady) return;
84
- await this._pgPool.query(`
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
- this._pgTableReady = true;
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
- // Atomic get-and-delete: use a pipeline or multi/exec
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
- 'SELECT * FROM hitl_pending WHERE resume_token = $1',
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
- // Delete regardless (one-time use)
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 {