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