agent-tool-forge 0.4.3 → 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.
@@ -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 cli/drift-monitor.js.
5
- * Started in forge-service.js when --mode=sidecar.
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
- * @returns {{ start(): void, stop(): void, runOnce(): void }}
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 runOnce() {
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() {
@@ -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
@@ -264,6 +264,53 @@ export function createSidecarRouter(ctx, options = {}) {
264
264
  return handleAdminConfig(req, res, ctx);
265
265
  }
266
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
+
267
314
  // ── Widget static file serving ─────────────────────────────────────────
268
315
  if (url.pathname.startsWith('/widget/')) {
269
316
  serveWidgetFile(req, res, widgetDir, sendJson);
@@ -1,31 +1,276 @@
1
1
  /**
2
- * Postgres-backed storage adapter for horizontal scaling.
2
+ * Postgres-backed storage adapters for horizontal scaling (0.4.x).
3
3
  *
4
- * Mirrors the SQLite query function signatures from `db.js` but uses a `pg` Pool.
5
- * Only loaded when `conversation.store === 'postgres'` (or `database.type === 'postgres'`) in config.
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 and run schema migrations.
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
- /** Close the connection pool. */
30
+ /** Drain and close the connection pool. */
19
31
  close(): Promise<void>;
20
32
 
21
- // ── Prompt versions ───────────────────────────────────────────────────────
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
- getActivePrompt(): Promise<object | null>;
24
- insertPromptVersion(row: { version: string; content: string; notes?: string | null }): Promise<number | null>;
25
- activatePromptVersion(id: number): Promise<void>;
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
- // ── User preferences ──────────────────────────────────────────────────────
112
+ // ── PostgresAgentRegistry ──────────────────────────────────────────────────
28
113
 
29
- getUserPreferences(userId: string): Promise<{ model: string | null; hitl_level: string | null } | null>;
30
- upsertUserPreferences(userId: string, prefs: { model?: string; hitlLevel?: string }): Promise<void>;
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
  }
@@ -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 {
@@ -633,21 +656,21 @@ export class PostgresChatAuditStore {
633
656
  async getStats() {
634
657
  const { rows } = await this._pool.query(`
635
658
  SELECT
636
- COUNT(*) AS total_sessions,
659
+ COALESCE(COUNT(*), 0) AS total_sessions,
637
660
  COALESCE(AVG(duration_ms), 0) AS avg_duration_ms,
638
661
  COALESCE(
639
662
  COUNT(*) FILTER (WHERE status_code >= 400)::float / NULLIF(COUNT(*), 0),
640
663
  0
641
664
  ) AS error_rate,
642
- COUNT(*) FILTER (WHERE created_at >= NOW() - INTERVAL '24 hours') AS messages_today
665
+ COALESCE(COUNT(*) FILTER (WHERE created_at >= NOW() - INTERVAL '24 hours'), 0) AS messages_today
643
666
  FROM chat_audit
644
667
  `);
645
- const row = rows[0];
668
+ const row = rows[0] ?? {};
646
669
  return {
647
- totalSessions: parseInt(row.total_sessions, 10),
648
- avgDurationMs: Math.round(parseFloat(row.avg_duration_ms)),
649
- errorRate: parseFloat(row.error_rate),
650
- messagesToday: parseInt(row.messages_today, 10)
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)
651
674
  };
652
675
  }
653
676
 
@@ -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 (uses INCR + EXPIRE per window key)
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
- * Factory creates a RateLimiter from forge config.
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;
@@ -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
- * @returns {RateLimiter}
165
+ * @param {object} [pgPool] — optional pg.Pool instance
166
+ * @returns {RateLimiter|PostgresRateLimiter}
107
167
  */
108
- export function makeRateLimiter(config, redis = null) {
109
- return new RateLimiter(config.rateLimit ?? {}, redis);
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
@@ -78,10 +78,13 @@ export async function createSidecar(config = {}, options = {}) {
78
78
  // Create HTTP server
79
79
  const server = createHttpServer(router);
80
80
 
81
- // Optional drift monitor
81
+ // Optional drift monitor — passes Postgres context when available
82
82
  let driftMonitor = null;
83
83
  if (enableDrift) {
84
- driftMonitor = createDriftMonitor(merged, db);
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);
85
88
  driftMonitor.start();
86
89
  }
87
90
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-tool-forge",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "description": "Production LLM agent sidecar + Claude Code skill library for building, testing, and running tool-calling agents.",
5
5
  "keywords": [
6
6
  "llm",