agent-tool-forge 0.4.1 → 0.4.5
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/lib/drift-background.js +69 -7
- package/lib/forge-service.js +57 -1
- package/lib/postgres-store.d.ts +257 -12
- package/lib/postgres-store.js +87 -15
- package/lib/rate-limiter.d.ts +21 -4
- package/lib/rate-limiter.js +67 -3
- package/lib/sidecar.d.ts +6 -0
- package/lib/sidecar.js +9 -2
- package/package.json +1 -1
package/lib/drift-background.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Background Drift Monitor — periodically checks all promoted tools for drift.
|
|
3
3
|
*
|
|
4
|
-
* Reuses checkDrift() and computeSuspects() from
|
|
5
|
-
*
|
|
4
|
+
* Reuses checkDrift() and computeSuspects() from drift-monitor.js for the
|
|
5
|
+
* SQLite path. When Postgres is configured, uses an async Postgres path that
|
|
6
|
+
* reads from PostgresEvalStore and PostgresStore instead.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import { getAllToolRegistry, insertDriftAlert } from './db.js';
|
|
@@ -14,16 +15,65 @@ const DEFAULT_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
|
|
14
15
|
* Create a background drift monitor.
|
|
15
16
|
*
|
|
16
17
|
* @param {object} config — forge config (drift.threshold, drift.windowSize)
|
|
17
|
-
* @param {import('better-sqlite3').Database} db
|
|
18
|
+
* @param {import('better-sqlite3').Database} db — SQLite db (used when pgCtx is null)
|
|
18
19
|
* @param {number} [intervalMs] — check interval (default 5 min)
|
|
19
|
-
* @
|
|
20
|
+
* @param {{ pgStore: object, evalStore: object, _pgPool: object } | null} [pgCtx]
|
|
21
|
+
* — Postgres context; when provided, the Postgres async path is used instead of SQLite.
|
|
22
|
+
* @returns {{ start(): void, stop(): void, runOnce(): Promise<void> }}
|
|
20
23
|
*/
|
|
21
|
-
export function createDriftMonitor(config, db, intervalMs = DEFAULT_INTERVAL_MS) {
|
|
24
|
+
export function createDriftMonitor(config, db, intervalMs = DEFAULT_INTERVAL_MS, pgCtx = null) {
|
|
22
25
|
let timer = null;
|
|
23
26
|
const threshold = config.drift?.threshold ?? 0.1;
|
|
24
27
|
const windowSize = config.drift?.windowSize ?? 5;
|
|
25
28
|
|
|
26
|
-
function
|
|
29
|
+
async function runOncePg({ pgStore, evalStore, _pgPool }) {
|
|
30
|
+
const allTools = await pgStore.getAllToolRegistry();
|
|
31
|
+
const promoted = allTools.filter(r => r.lifecycle_state === 'promoted');
|
|
32
|
+
|
|
33
|
+
for (const tool of promoted) {
|
|
34
|
+
const baseline = tool.baseline_pass_rate;
|
|
35
|
+
if (baseline == null) continue;
|
|
36
|
+
|
|
37
|
+
const history = await evalStore.getPerToolRunHistory(tool.tool_name, windowSize);
|
|
38
|
+
if (!history.length) continue;
|
|
39
|
+
|
|
40
|
+
const avg = history.reduce((s, r) => s + (r.pass_rate || 0), 0) / history.length;
|
|
41
|
+
const delta = baseline - avg;
|
|
42
|
+
if (delta < threshold) continue;
|
|
43
|
+
|
|
44
|
+
// Skip if an open alert already exists
|
|
45
|
+
const { rows: [existing] } = await _pgPool.query(
|
|
46
|
+
`SELECT id FROM drift_alerts WHERE tool_name = $1 AND status = 'open' LIMIT 1`,
|
|
47
|
+
[tool.tool_name]
|
|
48
|
+
);
|
|
49
|
+
if (existing) continue;
|
|
50
|
+
|
|
51
|
+
const now = new Date().toISOString();
|
|
52
|
+
const client = await _pgPool.connect();
|
|
53
|
+
try {
|
|
54
|
+
await client.query('BEGIN');
|
|
55
|
+
await client.query(
|
|
56
|
+
`INSERT INTO drift_alerts
|
|
57
|
+
(tool_name, detected_at, trigger_tools, baseline_rate, current_rate, delta, status)
|
|
58
|
+
VALUES ($1,$2,$3,$4,$5,$6,'open')`,
|
|
59
|
+
[tool.tool_name, now, '[]', baseline, avg, delta]
|
|
60
|
+
);
|
|
61
|
+
await client.query(
|
|
62
|
+
`UPDATE tool_registry SET lifecycle_state = 'flagged', flagged_at = $1
|
|
63
|
+
WHERE tool_name = $2 AND lifecycle_state != 'flagged'`,
|
|
64
|
+
[now, tool.tool_name]
|
|
65
|
+
);
|
|
66
|
+
await client.query('COMMIT');
|
|
67
|
+
} catch (e) {
|
|
68
|
+
await client.query('ROLLBACK');
|
|
69
|
+
process.stderr.write(`[drift-monitor] Postgres alert insert failed for ${tool.tool_name}: ${e.message}\n`);
|
|
70
|
+
} finally {
|
|
71
|
+
client.release();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function runOnceSqlite() {
|
|
27
77
|
try {
|
|
28
78
|
const tools = getAllToolRegistry(db).filter(r => r.lifecycle_state === 'promoted');
|
|
29
79
|
for (const tool of tools) {
|
|
@@ -44,10 +94,22 @@ export function createDriftMonitor(config, db, intervalMs = DEFAULT_INTERVAL_MS)
|
|
|
44
94
|
}
|
|
45
95
|
}
|
|
46
96
|
|
|
97
|
+
async function runOnce() {
|
|
98
|
+
if (pgCtx) {
|
|
99
|
+
try {
|
|
100
|
+
await runOncePg(pgCtx);
|
|
101
|
+
} catch (err) {
|
|
102
|
+
process.stderr.write(`[drift-monitor] Postgres error during check: ${err.message}\n`);
|
|
103
|
+
}
|
|
104
|
+
} else {
|
|
105
|
+
runOnceSqlite();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
47
109
|
return {
|
|
48
110
|
start() {
|
|
49
111
|
if (timer) return;
|
|
50
|
-
timer = setInterval(runOnce, intervalMs);
|
|
112
|
+
timer = setInterval(() => { runOnce().catch(() => {}); }, intervalMs);
|
|
51
113
|
timer.unref(); // Don't block process exit
|
|
52
114
|
},
|
|
53
115
|
stop() {
|
package/lib/forge-service.js
CHANGED
|
@@ -135,7 +135,7 @@ export async function buildSidecarContext(config, db, env = {}, opts = {}) {
|
|
|
135
135
|
const conversationStore = makeConversationStore(config, db, pgPool);
|
|
136
136
|
const hitlEngine = makeHitlEngine(config, db, redisClient, pgPool);
|
|
137
137
|
const verifierRunner = new VerifierRunner(db, config, pgPool ?? null);
|
|
138
|
-
const rateLimiter = makeRateLimiter(config, redisClient);
|
|
138
|
+
const rateLimiter = makeRateLimiter(config, redisClient, pgPool);
|
|
139
139
|
|
|
140
140
|
// configPath — used by admin handler to persist overlay changes.
|
|
141
141
|
// Defaults to process.cwd() so library consumers write config to their own
|
|
@@ -207,11 +207,14 @@ function serveWidgetFile(req, res, widgetDir, errorFn) {
|
|
|
207
207
|
* @param {object} [options]
|
|
208
208
|
* @param {string} [options.widgetDir] — directory for /widget/* static files (defaults to <project>/widget)
|
|
209
209
|
* @param {function} [options.mcpHandler] — optional async (req, res) handler for /mcp route
|
|
210
|
+
* @param {function} [options.customRoutes] — optional async (req, res, ctx) => boolean; called before
|
|
211
|
+
* the 404 fallback. Return true if the request was handled, false to fall through to 404.
|
|
210
212
|
* @returns {function(import('http').IncomingMessage, import('http').ServerResponse): Promise<void>}
|
|
211
213
|
*/
|
|
212
214
|
export function createSidecarRouter(ctx, options = {}) {
|
|
213
215
|
const widgetDir = options.widgetDir || resolve(__dirname, '..', 'widget');
|
|
214
216
|
const mcpHandler = options.mcpHandler || null;
|
|
217
|
+
const customRoutes = options.customRoutes || null;
|
|
215
218
|
|
|
216
219
|
return async (req, res) => {
|
|
217
220
|
const url = new URL(req.url, 'http://localhost');
|
|
@@ -261,12 +264,65 @@ export function createSidecarRouter(ctx, options = {}) {
|
|
|
261
264
|
return handleAdminConfig(req, res, ctx);
|
|
262
265
|
}
|
|
263
266
|
|
|
267
|
+
// ── Eval endpoints ─────────────────────────────────────────────────────
|
|
268
|
+
if (sidecarPath === '/agent-api/evals/summary' && req.method === 'GET') {
|
|
269
|
+
if (ctx.evalStore) {
|
|
270
|
+
try {
|
|
271
|
+
sendJson(res, 200, await ctx.evalStore.getEvalSummary());
|
|
272
|
+
} catch (err) {
|
|
273
|
+
sendJson(res, 500, { error: 'Failed to fetch eval summary' });
|
|
274
|
+
}
|
|
275
|
+
} else if (ctx.db) {
|
|
276
|
+
try {
|
|
277
|
+
const rows = ctx.db.prepare(
|
|
278
|
+
`SELECT tool_name, MAX(run_at) AS last_run,
|
|
279
|
+
SUM(total_cases) AS total_cases, SUM(passed) AS passed,
|
|
280
|
+
SUM(failed) AS failed,
|
|
281
|
+
ROUND(CAST(SUM(passed) AS REAL) / NULLIF(SUM(passed)+SUM(failed),0) * 100, 1) AS pass_rate
|
|
282
|
+
FROM eval_runs GROUP BY tool_name ORDER BY tool_name`
|
|
283
|
+
).all();
|
|
284
|
+
sendJson(res, 200, rows);
|
|
285
|
+
} catch { sendJson(res, 500, { error: 'Failed to fetch eval summary' }); }
|
|
286
|
+
} else {
|
|
287
|
+
sendJson(res, 200, []);
|
|
288
|
+
}
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (sidecarPath === '/agent-api/evals/runs' && req.method === 'GET') {
|
|
293
|
+
const limit = parseInt(url.searchParams.get('limit') || '50', 10);
|
|
294
|
+
const offset = parseInt(url.searchParams.get('offset') || '0', 10);
|
|
295
|
+
if (ctx.evalStore) {
|
|
296
|
+
try {
|
|
297
|
+
sendJson(res, 200, await ctx.evalStore.listRuns(limit, offset));
|
|
298
|
+
} catch (err) {
|
|
299
|
+
sendJson(res, 500, { error: 'Failed to fetch eval runs' });
|
|
300
|
+
}
|
|
301
|
+
} else if (ctx.db) {
|
|
302
|
+
try {
|
|
303
|
+
const rows = ctx.db.prepare(
|
|
304
|
+
'SELECT * FROM eval_runs ORDER BY run_at DESC LIMIT ? OFFSET ?'
|
|
305
|
+
).all(limit, offset);
|
|
306
|
+
sendJson(res, 200, rows);
|
|
307
|
+
} catch { sendJson(res, 500, { error: 'Failed to fetch eval runs' }); }
|
|
308
|
+
} else {
|
|
309
|
+
sendJson(res, 200, []);
|
|
310
|
+
}
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
264
314
|
// ── Widget static file serving ─────────────────────────────────────────
|
|
265
315
|
if (url.pathname.startsWith('/widget/')) {
|
|
266
316
|
serveWidgetFile(req, res, widgetDir, sendJson);
|
|
267
317
|
return;
|
|
268
318
|
}
|
|
269
319
|
|
|
320
|
+
// ── Custom routes (consumer-provided) ─────────────────────────────────
|
|
321
|
+
if (customRoutes) {
|
|
322
|
+
const handled = await customRoutes(req, res, ctx);
|
|
323
|
+
if (handled) return;
|
|
324
|
+
}
|
|
325
|
+
|
|
270
326
|
// ── 404 fallback ───────────────────────────────────────────────────────
|
|
271
327
|
sendJson(res, 404, { error: 'not found' });
|
|
272
328
|
};
|
package/lib/postgres-store.d.ts
CHANGED
|
@@ -1,31 +1,276 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Postgres-backed storage
|
|
2
|
+
* Postgres-backed storage adapters for horizontal scaling (0.4.x).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* This module exports seven classes that mirror the SQLite-backed store
|
|
5
|
+
* interfaces from db.js, prompt-store.js, preference-store.js,
|
|
6
|
+
* agent-registry.js, and the eval/audit/verifier sub-systems. All classes
|
|
7
|
+
* accept an existing `pg.Pool` (or create one internally) so they can share
|
|
8
|
+
* a single connection pool in sidecar deployments.
|
|
6
9
|
*
|
|
7
10
|
* Requires: `npm install pg`
|
|
11
|
+
* Optional — only loaded when `database.type === 'postgres'` (or
|
|
12
|
+
* `conversation.store === 'postgres'`) in forge config.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// ── PostgresStore ─────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Base store that owns the pg.Pool lifecycle and hosts the tool-registry
|
|
19
|
+
* methods. Call `connect()` before using any other method.
|
|
8
20
|
*/
|
|
9
21
|
export class PostgresStore {
|
|
10
22
|
constructor(pgConfig: { connectionString: string });
|
|
11
23
|
|
|
12
24
|
/**
|
|
13
|
-
* Connect to Postgres
|
|
25
|
+
* Connect to Postgres, run schema migrations, and return `this`.
|
|
14
26
|
* Must be called before any other method.
|
|
15
27
|
*/
|
|
16
28
|
connect(): Promise<this>;
|
|
17
29
|
|
|
18
|
-
/**
|
|
30
|
+
/** Drain and close the connection pool. */
|
|
19
31
|
close(): Promise<void>;
|
|
20
32
|
|
|
21
|
-
// ──
|
|
33
|
+
// ── Tool registry ────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
/** Return all tool_registry rows where lifecycle_state = 'promoted'. */
|
|
36
|
+
getPromotedTools(): Promise<object[]>;
|
|
37
|
+
|
|
38
|
+
/** Insert or update a tool_registry row. */
|
|
39
|
+
upsertToolRegistry(row: object): Promise<void>;
|
|
40
|
+
|
|
41
|
+
/** Return the tool_registry row for `toolName`, or null if not found. */
|
|
42
|
+
getToolRegistry(toolName: string): Promise<object | null>;
|
|
43
|
+
|
|
44
|
+
/** Return all tool_registry rows. */
|
|
45
|
+
getAllToolRegistry(): Promise<object[]>;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Apply lifecycle column updates (lifecycle_state, promoted_at,
|
|
49
|
+
* flagged_at, retired_at, replaced_by, baseline_pass_rate) to a single
|
|
50
|
+
* tool row. Unknown keys in `updates` are silently ignored.
|
|
51
|
+
*/
|
|
52
|
+
updateToolLifecycle(toolName: string, updates: Record<string, unknown>): Promise<void>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── PostgresPromptStore ────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Postgres-backed PromptStore — same interface as PromptStore in
|
|
59
|
+
* prompt-store.js. Accepts an existing pg.Pool created by
|
|
60
|
+
* buildSidecarContext.
|
|
61
|
+
*/
|
|
62
|
+
export class PostgresPromptStore {
|
|
63
|
+
constructor(pool: object);
|
|
64
|
+
|
|
65
|
+
/** Return the content of the currently active prompt, or '' if none. */
|
|
66
|
+
getActivePrompt(): Promise<string>;
|
|
67
|
+
|
|
68
|
+
/** Return all prompt_versions rows ordered by id DESC. */
|
|
69
|
+
getAllVersions(): Promise<object[]>;
|
|
70
|
+
|
|
71
|
+
/** Return a single prompt_versions row by id, or null if not found. */
|
|
72
|
+
getVersion(id: number): Promise<object | null>;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Insert a new prompt version (inactive) and return its generated id,
|
|
76
|
+
* or null on failure.
|
|
77
|
+
*/
|
|
78
|
+
createVersion(version: string, content: string, notes?: string | null): Promise<number | null>;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Deactivate all other versions and activate the row with the given id.
|
|
82
|
+
* Runs inside a transaction.
|
|
83
|
+
*/
|
|
84
|
+
activate(id: number | string): Promise<void>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── PostgresPreferenceStore ────────────────────────────────────────────────
|
|
22
88
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
89
|
+
/**
|
|
90
|
+
* Postgres-backed PreferenceStore — same interface as PreferenceStore in
|
|
91
|
+
* preference-store.js.
|
|
92
|
+
*/
|
|
93
|
+
export class PostgresPreferenceStore {
|
|
94
|
+
constructor(pool: object, config?: object, env?: Record<string, string>);
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Return the stored preferences for a user as `{ model, hitlLevel }`,
|
|
98
|
+
* or null if the user has no row.
|
|
99
|
+
*/
|
|
100
|
+
getUserPreferences(userId: string): Promise<object | null>;
|
|
101
|
+
|
|
102
|
+
/** Insert or update the user_preferences row for `userId`. */
|
|
103
|
+
setUserPreferences(userId: string, prefs: object): Promise<void>;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Resolve the effective runtime settings (model, hitlLevel, provider,
|
|
107
|
+
* apiKey) for a user, merging config defaults with any stored preferences.
|
|
108
|
+
*/
|
|
109
|
+
resolveEffective(userId: string, config: object, env: object): Promise<object>;
|
|
110
|
+
}
|
|
26
111
|
|
|
27
|
-
|
|
112
|
+
// ── PostgresAgentRegistry ──────────────────────────────────────────────────
|
|
28
113
|
|
|
29
|
-
|
|
30
|
-
|
|
114
|
+
/**
|
|
115
|
+
* Postgres-backed AgentRegistry — same interface as AgentRegistry in
|
|
116
|
+
* agent-registry.js.
|
|
117
|
+
*/
|
|
118
|
+
export class PostgresAgentRegistry {
|
|
119
|
+
constructor(config: object, pool: object);
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Return the default agent when `agentId` is null/undefined, or look up
|
|
123
|
+
* by id. Returns null when the agent is disabled or not found.
|
|
124
|
+
*/
|
|
125
|
+
resolveAgent(agentId: string | null): Promise<object | null>;
|
|
126
|
+
|
|
127
|
+
/** Return the agent_registry row for `agentId`, or null. */
|
|
128
|
+
getAgent(agentId: string): Promise<object | null>;
|
|
129
|
+
|
|
130
|
+
/** Return all agent_registry rows ordered by display_name. */
|
|
131
|
+
getAllAgents(): Promise<object[]>;
|
|
132
|
+
|
|
133
|
+
/** Insert or update an agent_registry row. */
|
|
134
|
+
upsertAgent(agent: object): Promise<void>;
|
|
135
|
+
|
|
136
|
+
/** Delete the agent_registry row for `agentId`. */
|
|
137
|
+
deleteAgent(agentId: string): Promise<void>;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Clear is_default on all agents and set it on `agentId`.
|
|
141
|
+
* No-ops (with implicit rollback) if the target agent is disabled.
|
|
142
|
+
* Runs inside a transaction.
|
|
143
|
+
*/
|
|
144
|
+
setDefault(agentId: string): Promise<void>;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Merge agent-level overrides (model, hitlLevel, tool policy, turn/token
|
|
148
|
+
* limits) on top of the base forge config and return the merged object.
|
|
149
|
+
*/
|
|
150
|
+
buildAgentConfig(config: object, agent: object | null): object;
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Resolve the system prompt for a request: agent.system_prompt >
|
|
154
|
+
* promptStore active prompt > config.systemPrompt > fallback string.
|
|
155
|
+
*/
|
|
156
|
+
resolveSystemPrompt(agent: object | null, promptStore: object, config: object): Promise<string>;
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Upsert all agents declared in `config.agents` that are either new or
|
|
160
|
+
* were previously seeded from config. Sets the default agent when
|
|
161
|
+
* `isDefault` is provided.
|
|
162
|
+
*/
|
|
163
|
+
seedFromConfig(): Promise<void>;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── PostgresEvalStore ──────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
/** Postgres-backed store for eval run results and per-case records. */
|
|
169
|
+
export class PostgresEvalStore {
|
|
170
|
+
constructor(pool: object);
|
|
171
|
+
|
|
172
|
+
/** Insert an eval_runs header row and return its generated id, or null. */
|
|
173
|
+
insertEvalRun(row: object): Promise<number | null>;
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Bulk-insert eval_run_cases rows inside a single transaction.
|
|
177
|
+
* No-ops when `rows` is empty.
|
|
178
|
+
*/
|
|
179
|
+
insertEvalRunCases(rows: object[]): Promise<void>;
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Return an aggregated summary (last_run, total_cases, passed, failed,
|
|
183
|
+
* pass_rate) grouped by tool_name.
|
|
184
|
+
*/
|
|
185
|
+
getEvalSummary(): Promise<object[]>;
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Return the most recent `windowSize` eval_runs rows for a single tool,
|
|
189
|
+
* ordered newest-first.
|
|
190
|
+
*/
|
|
191
|
+
getPerToolRunHistory(toolName: string, windowSize?: number): Promise<object[]>;
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Return a paginated list of eval_runs rows ordered by run_at DESC.
|
|
195
|
+
*/
|
|
196
|
+
listRuns(limit?: number, offset?: number): Promise<object[]>;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── PostgresChatAuditStore ─────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
/** Postgres-backed store for per-request chat audit records. */
|
|
202
|
+
export class PostgresChatAuditStore {
|
|
203
|
+
constructor(pool: object);
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Insert a chat_audit row and return its generated id, or null on
|
|
207
|
+
* failure.
|
|
208
|
+
*/
|
|
209
|
+
insertChatAudit(row: object): Promise<number | null>;
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Return aggregate statistics across all chat_audit rows:
|
|
213
|
+
* total sessions, average duration, error rate, and messages in the
|
|
214
|
+
* last 24 hours.
|
|
215
|
+
*/
|
|
216
|
+
getStats(): Promise<{
|
|
217
|
+
totalSessions: number;
|
|
218
|
+
avgDurationMs: number;
|
|
219
|
+
errorRate: number;
|
|
220
|
+
messagesToday: number;
|
|
221
|
+
}>;
|
|
222
|
+
|
|
223
|
+
/** Return a paginated list of chat_audit rows ordered by created_at DESC. */
|
|
224
|
+
getSessions(limit?: number, offset?: number): Promise<object[]>;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ── PostgresVerifierStore ──────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Postgres-backed store for verifier registry entries, tool bindings, and
|
|
231
|
+
* per-invocation results.
|
|
232
|
+
*/
|
|
233
|
+
export class PostgresVerifierStore {
|
|
234
|
+
constructor(pool: object);
|
|
235
|
+
|
|
236
|
+
/** Insert or update a verifier_registry row. */
|
|
237
|
+
upsertVerifier(row: object): Promise<void>;
|
|
238
|
+
|
|
239
|
+
/** Return the verifier_registry row for `name`, or null. */
|
|
240
|
+
getVerifier(name: string): Promise<object | null>;
|
|
241
|
+
|
|
242
|
+
/** Return all enabled verifier_registry rows ordered by aciru_order. */
|
|
243
|
+
getAllVerifiers(): Promise<object[]>;
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Delete a verifier and all of its tool bindings inside a transaction.
|
|
247
|
+
*/
|
|
248
|
+
deleteVerifier(name: string): Promise<void>;
|
|
249
|
+
|
|
250
|
+
/** Insert or update a verifier_tool_bindings row. */
|
|
251
|
+
upsertVerifierBinding(binding: object): Promise<void>;
|
|
252
|
+
|
|
253
|
+
/** Delete the binding between a verifier and a specific tool. */
|
|
254
|
+
removeVerifierBinding(verifierName: string, toolName: string): Promise<void>;
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Return all enabled verifier_registry rows that are bound to
|
|
258
|
+
* `toolName`, ordered by aciru_order.
|
|
259
|
+
*/
|
|
260
|
+
getVerifiersForTool(toolName: string): Promise<object[]>;
|
|
261
|
+
|
|
262
|
+
/** Return all verifier_tool_bindings rows for a given verifier. */
|
|
263
|
+
getBindingsForVerifier(verifierName: string): Promise<object[]>;
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Insert a verifier_results row and return its generated id, or null on
|
|
267
|
+
* failure.
|
|
268
|
+
*/
|
|
269
|
+
insertVerifierResult(row: object): Promise<number | null>;
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Return verifier_results rows ordered by created_at DESC. When
|
|
273
|
+
* `toolName` is provided, results are filtered to that tool.
|
|
274
|
+
*/
|
|
275
|
+
getResults(toolName?: string | null, limit?: number): Promise<object[]>;
|
|
31
276
|
}
|
package/lib/postgres-store.js
CHANGED
|
@@ -149,6 +149,29 @@ CREATE TABLE IF NOT EXISTS verifier_tool_bindings (
|
|
|
149
149
|
);
|
|
150
150
|
CREATE INDEX IF NOT EXISTS idx_vtb_tool_name
|
|
151
151
|
ON verifier_tool_bindings(tool_name, verifier_name);
|
|
152
|
+
|
|
153
|
+
-- drift_alerts (mirrors SQLite schema in db.js)
|
|
154
|
+
CREATE TABLE IF NOT EXISTS drift_alerts (
|
|
155
|
+
id SERIAL PRIMARY KEY,
|
|
156
|
+
tool_name TEXT NOT NULL,
|
|
157
|
+
detected_at TEXT NOT NULL,
|
|
158
|
+
trigger_tools TEXT,
|
|
159
|
+
baseline_rate REAL,
|
|
160
|
+
current_rate REAL,
|
|
161
|
+
delta REAL,
|
|
162
|
+
status TEXT NOT NULL DEFAULT 'open',
|
|
163
|
+
resolved_at TEXT
|
|
164
|
+
);
|
|
165
|
+
CREATE INDEX IF NOT EXISTS idx_drift_alerts_tool_status
|
|
166
|
+
ON drift_alerts(tool_name, status);
|
|
167
|
+
|
|
168
|
+
-- rate_limit_buckets (for PostgresRateLimiter)
|
|
169
|
+
CREATE TABLE IF NOT EXISTS rate_limit_buckets (
|
|
170
|
+
key TEXT NOT NULL,
|
|
171
|
+
window_start BIGINT NOT NULL,
|
|
172
|
+
count INTEGER NOT NULL DEFAULT 0,
|
|
173
|
+
PRIMARY KEY (key, window_start)
|
|
174
|
+
);
|
|
152
175
|
`;
|
|
153
176
|
|
|
154
177
|
export class PostgresStore {
|
|
@@ -241,21 +264,6 @@ export class PostgresStore {
|
|
|
241
264
|
`UPDATE tool_registry SET ${sets.join(', ')} WHERE tool_name = $${i}`, vals);
|
|
242
265
|
}
|
|
243
266
|
|
|
244
|
-
// ── Verifier results ──────────────────────────────────────────────────
|
|
245
|
-
|
|
246
|
-
async insertVerifierResult(row) {
|
|
247
|
-
const { rows } = await this._pool.query(
|
|
248
|
-
`INSERT INTO verifier_results
|
|
249
|
-
(session_id, tool_name, verifier_name, outcome, message, tool_call_input, tool_call_output, created_at)
|
|
250
|
-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`,
|
|
251
|
-
[
|
|
252
|
-
row.session_id ?? null, row.tool_name, row.verifier_name, row.outcome,
|
|
253
|
-
row.message ?? null, row.tool_call_input ?? null, row.tool_call_output ?? null,
|
|
254
|
-
new Date().toISOString()
|
|
255
|
-
]
|
|
256
|
-
);
|
|
257
|
-
return rows[0]?.id ?? null;
|
|
258
|
-
}
|
|
259
267
|
}
|
|
260
268
|
|
|
261
269
|
// ── PostgresPromptStore ────────────────────────────────────────────────────
|
|
@@ -611,6 +619,14 @@ export class PostgresEvalStore {
|
|
|
611
619
|
);
|
|
612
620
|
return rows;
|
|
613
621
|
}
|
|
622
|
+
|
|
623
|
+
async listRuns(limit = 50, offset = 0) {
|
|
624
|
+
const { rows } = await this._pool.query(
|
|
625
|
+
`SELECT * FROM eval_runs ORDER BY run_at DESC LIMIT $1 OFFSET $2`,
|
|
626
|
+
[limit, offset]
|
|
627
|
+
);
|
|
628
|
+
return rows;
|
|
629
|
+
}
|
|
614
630
|
}
|
|
615
631
|
|
|
616
632
|
// ── PostgresChatAuditStore ─────────────────────────────────────────────────
|
|
@@ -636,6 +652,35 @@ export class PostgresChatAuditStore {
|
|
|
636
652
|
);
|
|
637
653
|
return rows[0]?.id ?? null;
|
|
638
654
|
}
|
|
655
|
+
|
|
656
|
+
async getStats() {
|
|
657
|
+
const { rows } = await this._pool.query(`
|
|
658
|
+
SELECT
|
|
659
|
+
COALESCE(COUNT(*), 0) AS total_sessions,
|
|
660
|
+
COALESCE(AVG(duration_ms), 0) AS avg_duration_ms,
|
|
661
|
+
COALESCE(
|
|
662
|
+
COUNT(*) FILTER (WHERE status_code >= 400)::float / NULLIF(COUNT(*), 0),
|
|
663
|
+
0
|
|
664
|
+
) AS error_rate,
|
|
665
|
+
COALESCE(COUNT(*) FILTER (WHERE created_at >= NOW() - INTERVAL '24 hours'), 0) AS messages_today
|
|
666
|
+
FROM chat_audit
|
|
667
|
+
`);
|
|
668
|
+
const row = rows[0] ?? {};
|
|
669
|
+
return {
|
|
670
|
+
totalSessions: parseInt(row.total_sessions ?? '0', 10),
|
|
671
|
+
avgDurationMs: Math.round(parseFloat(row.avg_duration_ms ?? '0')),
|
|
672
|
+
errorRate: parseFloat(row.error_rate ?? '0'),
|
|
673
|
+
messagesToday: parseInt(row.messages_today ?? '0', 10)
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
async getSessions(limit = 20, offset = 0) {
|
|
678
|
+
const { rows } = await this._pool.query(
|
|
679
|
+
`SELECT * FROM chat_audit ORDER BY created_at DESC LIMIT $1 OFFSET $2`,
|
|
680
|
+
[limit, offset]
|
|
681
|
+
);
|
|
682
|
+
return rows;
|
|
683
|
+
}
|
|
639
684
|
}
|
|
640
685
|
|
|
641
686
|
// ── PostgresVerifierStore ──────────────────────────────────────────────────
|
|
@@ -724,4 +769,31 @@ export class PostgresVerifierStore {
|
|
|
724
769
|
`SELECT * FROM verifier_tool_bindings WHERE verifier_name = $1`, [verifierName]);
|
|
725
770
|
return rows;
|
|
726
771
|
}
|
|
772
|
+
|
|
773
|
+
async insertVerifierResult(row) {
|
|
774
|
+
const { rows } = await this._pool.query(
|
|
775
|
+
`INSERT INTO verifier_results
|
|
776
|
+
(session_id, tool_name, verifier_name, outcome, message, tool_call_input, tool_call_output, created_at)
|
|
777
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id`,
|
|
778
|
+
[row.session_id ?? null, row.tool_name, row.verifier_name, row.outcome,
|
|
779
|
+
row.message ?? null, row.tool_call_input ?? null, row.tool_call_output ?? null,
|
|
780
|
+
new Date().toISOString()]
|
|
781
|
+
);
|
|
782
|
+
return rows[0]?.id ?? null;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
async getResults(toolName = null, limit = 100) {
|
|
786
|
+
if (toolName) {
|
|
787
|
+
const { rows } = await this._pool.query(
|
|
788
|
+
`SELECT * FROM verifier_results WHERE tool_name = $1 ORDER BY created_at DESC LIMIT $2`,
|
|
789
|
+
[toolName, limit]
|
|
790
|
+
);
|
|
791
|
+
return rows;
|
|
792
|
+
}
|
|
793
|
+
const { rows } = await this._pool.query(
|
|
794
|
+
`SELECT * FROM verifier_results ORDER BY created_at DESC LIMIT $1`,
|
|
795
|
+
[limit]
|
|
796
|
+
);
|
|
797
|
+
return rows;
|
|
798
|
+
}
|
|
727
799
|
}
|
package/lib/rate-limiter.d.ts
CHANGED
|
@@ -7,8 +7,9 @@ export interface RateLimitResult {
|
|
|
7
7
|
/**
|
|
8
8
|
* Fixed-window per-user per-route rate limiter.
|
|
9
9
|
*
|
|
10
|
-
* Backend is selected automatically
|
|
11
|
-
* - Redis — if a Redis client is provided
|
|
10
|
+
* Backend is selected automatically by `makeRateLimiter`:
|
|
11
|
+
* - Redis — if a Redis client is provided
|
|
12
|
+
* - Postgres — if a pg.Pool is provided as the third argument
|
|
12
13
|
* - Memory — in-process Map fallback, resets on window boundary
|
|
13
14
|
*
|
|
14
15
|
* Only counts authenticated requests — limits by `userId`, not IP.
|
|
@@ -24,7 +25,23 @@ export class RateLimiter {
|
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
|
-
*
|
|
28
|
+
* Postgres-backed rate limiter. Uses an atomic `INSERT … ON CONFLICT DO UPDATE`
|
|
29
|
+
* on the `rate_limit_buckets` table for horizontal-scale durability.
|
|
30
|
+
*/
|
|
31
|
+
export class PostgresRateLimiter {
|
|
32
|
+
constructor(config?: object, pgPool?: object | null);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Check whether a request is within the rate limit and increment the counter.
|
|
36
|
+
* Always returns `{ allowed: true }` when rate limiting is disabled.
|
|
37
|
+
* Rejects if the database is unavailable.
|
|
38
|
+
*/
|
|
39
|
+
check(userId: string, route: string): Promise<RateLimitResult>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Factory — creates a rate limiter from forge config.
|
|
28
44
|
* Reads `config.rateLimit` for `enabled`, `windowMs`, and `maxRequests`.
|
|
45
|
+
* Priority: Redis > Postgres > in-memory.
|
|
29
46
|
*/
|
|
30
|
-
export function makeRateLimiter(config: object, redis?: object | null): RateLimiter;
|
|
47
|
+
export function makeRateLimiter(config: object, redis?: object | null, pgPool?: object | null): RateLimiter | PostgresRateLimiter;
|
package/lib/rate-limiter.js
CHANGED
|
@@ -97,14 +97,78 @@ export class RateLimiter {
|
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Postgres-backed fixed-window rate limiter.
|
|
102
|
+
* Uses an atomic INSERT ... ON CONFLICT DO UPDATE to count requests.
|
|
103
|
+
* Requires the rate_limit_buckets table (created by postgres-store.js SCHEMA).
|
|
104
|
+
*
|
|
105
|
+
* @param {object} config — forge rateLimit config block
|
|
106
|
+
* @param {object} pgPool — pg.Pool instance
|
|
107
|
+
*/
|
|
108
|
+
export class PostgresRateLimiter {
|
|
109
|
+
constructor(config = {}, pgPool) {
|
|
110
|
+
this._enabled = config.enabled ?? false;
|
|
111
|
+
this._windowMs = config.windowMs ?? 60_000;
|
|
112
|
+
this._maxRequests = config.maxRequests ?? 60;
|
|
113
|
+
this._pgPool = pgPool;
|
|
114
|
+
// Cleanup stale windows every 5 minutes
|
|
115
|
+
if (this._enabled) {
|
|
116
|
+
this._cleanupTimer = setInterval(async () => {
|
|
117
|
+
const cutoff = Math.floor(Date.now() / this._windowMs) * this._windowMs - this._windowMs;
|
|
118
|
+
try {
|
|
119
|
+
await this._pgPool.query(
|
|
120
|
+
'DELETE FROM rate_limit_buckets WHERE window_start < $1', [cutoff]);
|
|
121
|
+
} catch { /* non-fatal */ }
|
|
122
|
+
}, 5 * 60 * 1000).unref();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async check(userId, route) {
|
|
127
|
+
if (!this._enabled) return { allowed: true };
|
|
128
|
+
if (!userId || !route) return { allowed: true };
|
|
129
|
+
|
|
130
|
+
const windowMs = this._windowMs;
|
|
131
|
+
const maxRequests = this._maxRequests;
|
|
132
|
+
const now = Date.now();
|
|
133
|
+
const windowStart = Math.floor(now / windowMs) * windowMs;
|
|
134
|
+
const key = `\x00${userId}\x00${route}`;
|
|
135
|
+
|
|
136
|
+
let rows;
|
|
137
|
+
try {
|
|
138
|
+
({ rows } = await this._pgPool.query(
|
|
139
|
+
`INSERT INTO rate_limit_buckets (key, window_start, count)
|
|
140
|
+
VALUES ($1, $2, 1)
|
|
141
|
+
ON CONFLICT (key, window_start) DO UPDATE
|
|
142
|
+
SET count = rate_limit_buckets.count + 1
|
|
143
|
+
RETURNING count`,
|
|
144
|
+
[key, windowStart]
|
|
145
|
+
));
|
|
146
|
+
} catch (err) {
|
|
147
|
+
console.error('[forge-rate-limiter] pgPool.query failed:', err.message ?? err);
|
|
148
|
+
return { allowed: true }; // fail open on DB error
|
|
149
|
+
}
|
|
150
|
+
const count = rows[0]?.count ?? 1;
|
|
151
|
+
if (count > maxRequests) {
|
|
152
|
+
const retryAfter = Math.max(1, Math.ceil((windowStart + windowMs - now) / 1000));
|
|
153
|
+
return { allowed: false, retryAfter };
|
|
154
|
+
}
|
|
155
|
+
return { allowed: true };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
100
159
|
/**
|
|
101
160
|
* Factory — creates a RateLimiter from forge config.
|
|
102
161
|
* Auto-passes Redis client if available (set by buildSidecarContext).
|
|
103
162
|
*
|
|
104
163
|
* @param {object} config — merged forge config
|
|
105
164
|
* @param {object} [redis] — optional Redis client
|
|
106
|
-
* @
|
|
165
|
+
* @param {object} [pgPool] — optional pg.Pool instance
|
|
166
|
+
* @returns {RateLimiter|PostgresRateLimiter}
|
|
107
167
|
*/
|
|
108
|
-
export function makeRateLimiter(config, redis = null) {
|
|
109
|
-
|
|
168
|
+
export function makeRateLimiter(config, redis = null, pgPool = null) {
|
|
169
|
+
const rlConfig = config.rateLimit ?? {};
|
|
170
|
+
if (!redis && pgPool) {
|
|
171
|
+
return new PostgresRateLimiter(rlConfig, pgPool);
|
|
172
|
+
}
|
|
173
|
+
return new RateLimiter(rlConfig, redis);
|
|
110
174
|
}
|
package/lib/sidecar.d.ts
CHANGED
|
@@ -12,6 +12,7 @@ export interface SidecarOptions {
|
|
|
12
12
|
autoListen?: boolean;
|
|
13
13
|
enableDrift?: boolean;
|
|
14
14
|
widgetDir?: string;
|
|
15
|
+
customRoutes?: (req: object, res: object, ctx: SidecarContext) => Promise<boolean> | boolean;
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
export interface SidecarContext {
|
|
@@ -27,6 +28,11 @@ export interface SidecarContext {
|
|
|
27
28
|
config: SidecarConfig;
|
|
28
29
|
env: Record<string, string>;
|
|
29
30
|
configPath?: string;
|
|
31
|
+
evalStore: object | null;
|
|
32
|
+
chatAuditStore: object | null;
|
|
33
|
+
verifierStore: object | null;
|
|
34
|
+
pgStore: object | null;
|
|
35
|
+
_pgPool: object | null;
|
|
30
36
|
[key: string]: unknown;
|
|
31
37
|
}
|
|
32
38
|
|
package/lib/sidecar.js
CHANGED
|
@@ -29,6 +29,8 @@ import { createDriftMonitor } from './drift-background.js';
|
|
|
29
29
|
* @param {boolean} [options.autoListen=true] — start listening immediately
|
|
30
30
|
* @param {boolean} [options.enableDrift=false] — start background drift monitor
|
|
31
31
|
* @param {string} [options.widgetDir] — custom widget directory
|
|
32
|
+
* @param {function} [options.customRoutes] — async (req, res, ctx) => boolean; inject custom routes
|
|
33
|
+
* before the built-in 404 handler. Return true if the request was handled, false to fall through.
|
|
32
34
|
* @returns {Promise<{ server: import('http').Server, ctx: object, close: () => void }>}
|
|
33
35
|
*/
|
|
34
36
|
export async function createSidecar(config = {}, options = {}) {
|
|
@@ -40,6 +42,7 @@ export async function createSidecar(config = {}, options = {}) {
|
|
|
40
42
|
autoListen = true,
|
|
41
43
|
enableDrift = false,
|
|
42
44
|
widgetDir,
|
|
45
|
+
customRoutes,
|
|
43
46
|
} = options;
|
|
44
47
|
|
|
45
48
|
// Merge defaults first so validateConfig sees a fully-populated object (M1).
|
|
@@ -69,15 +72,19 @@ export async function createSidecar(config = {}, options = {}) {
|
|
|
69
72
|
// Build request handler
|
|
70
73
|
const routerOpts = {};
|
|
71
74
|
if (widgetDir) routerOpts.widgetDir = widgetDir;
|
|
75
|
+
if (customRoutes) routerOpts.customRoutes = customRoutes;
|
|
72
76
|
const router = createSidecarRouter(ctx, routerOpts);
|
|
73
77
|
|
|
74
78
|
// Create HTTP server
|
|
75
79
|
const server = createHttpServer(router);
|
|
76
80
|
|
|
77
|
-
// Optional drift monitor
|
|
81
|
+
// Optional drift monitor — passes Postgres context when available
|
|
78
82
|
let driftMonitor = null;
|
|
79
83
|
if (enableDrift) {
|
|
80
|
-
|
|
84
|
+
const pgCtx = (ctx._pgPool && ctx.pgStore && ctx.evalStore)
|
|
85
|
+
? { pgStore: ctx.pgStore, evalStore: ctx.evalStore, _pgPool: ctx._pgPool }
|
|
86
|
+
: null;
|
|
87
|
+
driftMonitor = createDriftMonitor(merged, db, undefined, pgCtx);
|
|
81
88
|
driftMonitor.start();
|
|
82
89
|
}
|
|
83
90
|
|