diff --git a/.changeset/vnext-inprocess-adapters.md b/.changeset/vnext-inprocess-adapters.md new file mode 100644 index 0000000..c7b5c5c --- /dev/null +++ b/.changeset/vnext-inprocess-adapters.md @@ -0,0 +1,26 @@ +--- +'@reaatech/agent-mesh': minor +'@reaatech/agent-mesh-router': minor +'@reaatech/agent-mesh-postgres': minor +'@reaatech/agent-mesh-redis': minor +--- + +agent-mesh v-next, part 2 — in-process transport and Postgres/Redis persistence: + +- **router / core: in-process transport.** Agents can now be dispatched in-process + (`type: 'inprocess'`, no HTTP hop) via `registerInProcessAgent`, alongside the MCP + transport. `dispatchToAgent` routes by `agent.type` and threads an optional + `metadata` passthrough (e.g. a tenant `orgId`) into the `ContextPacket`. Existing + `type: 'mcp'` agents are unchanged. +- **core: consolidate the duplicated `AgentConfig`.** It was defined in both core and + the registry; it now lives once in core (with the SSRF-safe endpoint check) and the + registry re-exports it. `type` is `enum(['mcp','inprocess'])` and `endpoint` is + optional (required for mcp via a refine). +- **new `@reaatech/agent-mesh-postgres`** — Postgres-backed `SessionStore` + + `BreakerStore` adapters (+ `ensureSchema` / `SCHEMA_SQL`), leader election via a + single-row lease with `SELECT … FOR UPDATE`. +- **new `@reaatech/agent-mesh-redis`** — Redis-backed `SessionStore` + `BreakerStore` + adapters, leader election via a `SET NX PX` lease. + +Docs, examples, and package READMEs updated for the pluggable classifier/persistence +and the in-process transport. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4cf3334..b32d20f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -107,10 +107,16 @@ tsup (dual CJS/ESM build) + Biome (lint/format) + Vitest (testing). ### 1. Stateless by Design - No in-memory state shared across requests -- All session state persisted to Firestore -- Circuit breaker state persisted with leader election +- All session state persisted via a pluggable `SessionStore` (Firestore default; Postgres/Redis/in-memory adapters) +- Circuit breaker state persisted via a pluggable `BreakerStore` with leader election (Firestore default; Postgres/Redis) - Enables horizontal scaling on Cloud Run / Kubernetes +### 1b. Pluggable Providers +- **Classifier** — `ClassifierProvider` (Gemini default; inject any model, incl. a host-resolved one). No hard dependency on a specific model vendor. +- **Persistence** — `SessionStore` / `BreakerStore` interfaces; Firestore is the default impl, Postgres/Redis/in-memory are drop-in. +- **Transport** — agents dispatch over MCP (`type: 'mcp'`) or **in-process** (`type: 'inprocess'`) for co-located handlers, with a `metadata` passthrough for host context. +- Defaults are unchanged for existing deployments — every provider is opt-in via injection. + ### 2. Zero-Trust Security - All inputs validated with Zod schemas - SSRF protection on agent endpoint URLs diff --git a/README.md b/README.md index e00087c..1d358b7 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,10 @@ This monorepo provides the full orchestrator stack for building multi-agent AI s ## Features -- **Confidence-gated routing** — Gemini Flash intent classification with a 5-rule decision tree (route / clarify / fallback) and per-agent confidence thresholds -- **MCP agent dispatch** — StreamableHTTP transport with connection pooling, retries, and Zod-validated responses -- **Per-agent circuit breakers** — Three-state (CLOSED/OPEN/HALF_OPEN) with exponential backoff, Firestore persistence, and cross-instance leader election -- **Session management** — Firestore-backed multi-turn sessions with sliding TTL, turn history, workflow state passthrough, and classification bypass +- **Confidence-gated routing** — a 5-rule decision tree (route / clarify / fallback) with per-agent confidence thresholds. **Pluggable classifier** (`ClassifierProvider`): Gemini Flash by default, or inject any model — including a host-resolved one. +- **Agent dispatch** — StreamableHTTP MCP transport (connection pooling, retries, Zod-validated responses) **or an in-process transport** (`type: 'inprocess'`) for agents co-located with the orchestrator, with a `metadata` passthrough for host context (e.g. a tenant `orgId`). +- **Per-agent circuit breakers** — Three-state (CLOSED/OPEN/HALF_OPEN) with exponential backoff and cross-instance leader election. **Pluggable persistence** (`BreakerStore`): Firestore by default, or Postgres/Redis. +- **Session management** — multi-turn sessions with sliding TTL, turn history, workflow-state passthrough, and classification bypass. **Pluggable persistence** (`SessionStore`): Firestore by default, or Postgres/Redis/in-memory. - **Hot-reload agent registry** — YAML-based agent configuration with `${ENV_VAR}` expansion, SIGHUP reload, SSRF-safe URL validation, and cross-agent invariant enforcement - **Express gateway** — API key authentication (Secret Manager-backed), token bucket rate limiting, TLS enforcement with HSTS, and Slack profile resolution - **MCP server interface** — Exposes the orchestrator as an MCP-compliant agent with JSON-RPC 2.0 routing, tool registration, and SSE transport @@ -119,13 +119,15 @@ See the [`examples/orchestrator/`](./examples/orchestrator/) directory for the c | ------- | ----------- | | [`@reaatech/agent-mesh`](./packages/core) | Core domain types, Zod schemas, environment config, and shared constants | | [`@reaatech/agent-mesh-registry`](./packages/registry) | Agent YAML loader, SIGHUP hot-reload, SSRF-safe URL validation | -| [`@reaatech/agent-mesh-session`](./packages/session) | Firestore-backed multi-turn session management | -| [`@reaatech/agent-mesh-classifier`](./packages/classifier) | Gemini Flash intent classification with mock fallback | +| [`@reaatech/agent-mesh-session`](./packages/session) | Multi-turn session management with a pluggable `SessionStore` (Firestore default + in-memory) | +| [`@reaatech/agent-mesh-classifier`](./packages/classifier) | Intent classification with a pluggable `ClassifierProvider` (Gemini Flash default + mock) | | [`@reaatech/agent-mesh-confidence`](./packages/confidence) | Confidence-gated routing decision tree and clarification cache | -| [`@reaatech/agent-mesh-router`](./packages/router) | MCP-based agent dispatch with connection pooling | +| [`@reaatech/agent-mesh-router`](./packages/router) | Agent dispatch — MCP (pooled) or in-process transport | | [`@reaatech/agent-mesh-gateway`](./packages/gateway) | Express middleware (auth, rate limiting, TLS) and request handler | | [`@reaatech/agent-mesh-mcp-server`](./packages/mcp-server) | MCP server exposing orchestrator with JSON-RPC 2.0 and SSE transport | -| [`@reaatech/agent-mesh-utils`](./packages/utils) | Per-agent circuit breaker with Firestore persistence and leader election | +| [`@reaatech/agent-mesh-utils`](./packages/utils) | Per-agent circuit breaker with a pluggable `BreakerStore` (Firestore default) + leader election | +| [`@reaatech/agent-mesh-postgres`](./packages/postgres) | Postgres-backed `SessionStore` + `BreakerStore` adapters | +| [`@reaatech/agent-mesh-redis`](./packages/redis) | Redis-backed `SessionStore` + `BreakerStore` adapters | | [`@reaatech/agent-mesh-observability`](./packages/observability) | Structured logging, OpenTelemetry tracing/metrics, and audit events | ## Documentation diff --git a/examples/orchestrator/README.md b/examples/orchestrator/README.md index 0d9dd11..3342095 100644 --- a/examples/orchestrator/README.md +++ b/examples/orchestrator/README.md @@ -8,6 +8,57 @@ Reference deployment example for the agent-mesh monorepo. npm run build && node dist/index.js ``` +## Extending + +agent-mesh's provider seams let you swap backends and models without forking. Wire +these **before** starting the server (e.g. at the top of `main()`). + +### Bring your own classifier (instead of Gemini) + +```ts +import { setClassifier } from './wherever-you-hold-it'; // see @reaatech/agent-mesh-classifier +import { classifierService, createClassifier, type ClassifierProvider } from '@reaatech/agent-mesh-classifier'; + +const myClassifier: ClassifierProvider = { + classify: async (userInput, registry) => { + // call your own model; return a ClassifierOutput + }, +}; +const classifier = createClassifier(myClassifier); // default (no arg) = Gemini + mock fallback +``` + +### Swap the session + breaker persistence (Postgres/Redis instead of Firestore) + +```ts +import { Pool } from 'pg'; +import { PostgresSessionStore, PostgresBreakerStore, ensureSchema } from '@reaatech/agent-mesh-postgres'; +import { setSessionStore } from '@reaatech/agent-mesh-session'; +import { setBreakerStore } from '@reaatech/agent-mesh-utils'; + +const pool = new Pool({ connectionString: process.env.DATABASE_URL }); +await ensureSchema(pool); +setSessionStore(new PostgresSessionStore(pool)); +setBreakerStore(new PostgresBreakerStore(pool)); + +// …or the in-memory adapters for local/dev: +// import { InMemorySessionStore } from '@reaatech/agent-mesh-session'; +// setSessionStore(new InMemorySessionStore()); +``` + +### Register an in-process agent (no HTTP hop) + +Give the agent `type: 'inprocess'` in its YAML (no `endpoint` needed), then register +a handler: + +```ts +import { registerInProcessAgent } from '@reaatech/agent-mesh-router'; + +registerInProcessAgent('local-agent', async (ctx) => { + // ctx.metadata carries host context (e.g. a tenant orgId) + return { content: `handled: ${ctx.raw_input}`, workflow_complete: true }; +}); +``` + ## License MIT diff --git a/packages/classifier/README.md b/packages/classifier/README.md index 5dd7e96..0a62e60 100644 --- a/packages/classifier/README.md +++ b/packages/classifier/README.md @@ -1,5 +1,8 @@ # @reaatech/agent-mesh-classifier +> **Pluggable** via `ClassifierProvider` — inject any intent classifier with +> `createClassifier(provider)`. Default (no arg) = Gemini Flash + mock fallback. + [![npm version](https://img.shields.io/npm/v/@reaatech/agent-mesh-classifier.svg)](https://www.npmjs.com/package/@reaatech/agent-mesh-classifier) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/reaatech/agent-mesh/blob/main/LICENSE) [![CI](https://github.com/reaatech/agent-mesh/actions/workflows/ci.yml/badge.svg)](https://github.com/reaatech/agent-mesh/actions/workflows/ci.yml) diff --git a/packages/core/src/domain.ts b/packages/core/src/domain.ts index 4b032be..f38d02c 100644 --- a/packages/core/src/domain.ts +++ b/packages/core/src/domain.ts @@ -1,4 +1,28 @@ import { z } from 'zod'; +import { PRIVATE_IP_RANGES } from './constants.js'; + +function isSsrfSafeUrl(url: string): boolean { + try { + const parsed = new URL(url); + if (!['http:', 'https:'].includes(parsed.protocol)) { + return false; + } + const hostname = parsed.hostname.toLowerCase(); + for (const pattern of PRIVATE_IP_RANGES) { + if (pattern.test(hostname)) { + return false; + } + } + return true; + } catch { + return false; + } +} + +const ssrfSafeUrl = z.string().refine((url) => isSsrfSafeUrl(url), { + message: + 'Endpoint URL is not allowed: localhost and private IP ranges are rejected for SSRF protection.', +}); export const IncomingRequestSchema = z.object({ input: z.string().min(1, 'input is required').max(10000, 'input too long'), @@ -38,18 +62,36 @@ export const ClassifierOutputSchema = z.object({ export type ClassifierOutput = z.infer; -export const AgentConfigSchema = z.object({ - agent_id: z.string().regex(/^[a-z0-9-]+$/, 'agent_id must be lowercase with hyphens'), - display_name: z.string().min(1), - description: z.string().min(1), - endpoint: z.string().url(), - type: z.literal('mcp'), - is_default: z.boolean().default(false), - confidence_threshold: z.number().min(0).max(1).default(0), - clarification_required: z.boolean().default(false), - clarification_context: z.string().optional(), - examples: z.array(z.string()).min(1), -}); +// Canonical agent config — the single source of truth. The registry package +// re-exports this (it owns only the AgentRegistry array schema + invariants). +export const AgentConfigSchema = z + .object({ + agent_id: z + .string() + .min(1, 'agent_id is required') + .regex(/^[a-z0-9-]+$/, 'agent_id must be lowercase alphanumeric with hyphens'), + display_name: z.string().min(1, 'display_name is required').max(200), + description: z + .string() + .min(1, 'description is required') + .max(5000, 'description too long (max 5000 chars)'), + // Required for `type: 'mcp'` (remote agent); omitted for `type: 'inprocess'` + // (a locally-registered handler). Enforced by the refine below. + endpoint: ssrfSafeUrl.optional(), + type: z.enum(['mcp', 'inprocess']).default('mcp'), + is_default: z.boolean().default(false), + confidence_threshold: z.number().min(0).max(1).default(0), + clarification_required: z.boolean().default(false), + clarification_context: z.string().max(500).optional(), + examples: z + .array(z.string().min(1).max(500)) + .min(1, 'at least one example is required') + .max(20, 'maximum 20 examples allowed'), + }) + .refine((cfg) => cfg.type !== 'mcp' || Boolean(cfg.endpoint), { + message: 'endpoint is required for mcp agents', + path: ['endpoint'], + }); export type AgentConfig = z.infer; diff --git a/packages/postgres/README.md b/packages/postgres/README.md new file mode 100644 index 0000000..3b3b870 --- /dev/null +++ b/packages/postgres/README.md @@ -0,0 +1,37 @@ +# @reaatech/agent-mesh-postgres + +Postgres-backed `SessionStore` and `BreakerStore` adapters for +[agent-mesh](https://github.com/reaatech/agent-mesh) — a drop-in alternative to +the default Firestore backend. + +```bash +pnpm add @reaatech/agent-mesh-postgres pg +``` + +## Usage + +```ts +import { Pool } from 'pg'; +import { + PostgresSessionStore, + PostgresBreakerStore, + ensureSchema, +} from '@reaatech/agent-mesh-postgres'; +import { setSessionStore } from '@reaatech/agent-mesh-session'; +import { setBreakerStore } from '@reaatech/agent-mesh-utils'; + +const pool = new Pool({ connectionString: process.env.DATABASE_URL }); + +await ensureSchema(pool); // idempotent DDL — run once on boot (or use SCHEMA_SQL in a migration) + +setSessionStore(new PostgresSessionStore(pool)); +setBreakerStore(new PostgresBreakerStore(pool)); +``` + +Tables (created by `ensureSchema` / `SCHEMA_SQL`): `agent_mesh_sessions`, +`agent_mesh_circuit_breakers`, and `agent_mesh_leader` (a single-row lease used for +leader election via `SELECT … FOR UPDATE`). `pg` is a peer dependency. + +> **Note:** unit-tested against a mock pool (SQL issuance + row mapping). Run a smoke +> test against a live Postgres (jsonb, `FOR UPDATE`, timestamptz, multi-instance +> leader election) before production use. diff --git a/packages/postgres/package.json b/packages/postgres/package.json new file mode 100644 index 0000000..c2865c2 --- /dev/null +++ b/packages/postgres/package.json @@ -0,0 +1,52 @@ +{ + "name": "@reaatech/agent-mesh-postgres", + "version": "0.1.0", + "type": "module", + "description": "Postgres-backed SessionStore and BreakerStore adapters for agent-mesh", + "license": "MIT", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsup src/index.ts --format cjs,esm --dts --clean", + "test": "vitest run", + "test:coverage": "vitest run --coverage", + "clean": "rm -rf dist", + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "pg": ">=8" + }, + "dependencies": { + "@reaatech/agent-mesh": "workspace:*" + }, + "devDependencies": { + "@types/node": "^25.6.0", + "@types/pg": "^8.11.10", + "pg": "^8.13.1", + "tsup": "^8.4.0" + }, + "repository": { + "type": "git", + "url": "https://github.com/reaatech/agent-mesh.git", + "directory": "packages/postgres" + }, + "author": "Rick Somers (https://reaatech.com)", + "homepage": "https://github.com/reaatech/agent-mesh/tree/main/packages/postgres#readme", + "bugs": { + "url": "https://github.com/reaatech/agent-mesh/issues" + } +} diff --git a/packages/postgres/src/breaker.store.ts b/packages/postgres/src/breaker.store.ts new file mode 100644 index 0000000..f33c974 --- /dev/null +++ b/packages/postgres/src/breaker.store.ts @@ -0,0 +1,130 @@ +import type { + BreakerStore, + CircuitBreakerState, + CircuitState, + LeaderState, +} from '@reaatech/agent-mesh'; +import type { Pool } from 'pg'; + +const BREAKERS = 'agent_mesh_circuit_breakers'; +const LEADER = 'agent_mesh_leader'; +const LEADER_ID = 'circuit_breaker_sync_leader'; + +interface BreakerRow { + agent_id: string; + state: CircuitState; + failure_count: number; + success_count: number; + last_failure_time: string | null; + last_state_change: string; + half_open_calls: number; + backoff_multiplier: number; +} + +function mapRow(row: BreakerRow): CircuitBreakerState { + return { + agent_id: row.agent_id, + state: row.state, + failure_count: Number(row.failure_count), + success_count: Number(row.success_count), + last_failure_time: row.last_failure_time == null ? undefined : Number(row.last_failure_time), + last_state_change: Number(row.last_state_change), + half_open_calls: Number(row.half_open_calls), + backoff_multiplier: Number(row.backoff_multiplier), + }; +} + +/** + * Postgres-backed {@link BreakerStore}. Leader election uses a single-row lease + * table with `SELECT … FOR UPDATE` (a new instance can take over only once the + * lease has expired). Run `ensureSchema` first. + */ +export class PostgresBreakerStore implements BreakerStore { + constructor(private readonly pool: Pool) {} + + async load(agentId: string): Promise { + const res = await this.pool.query(`SELECT * FROM ${BREAKERS} WHERE agent_id = $1`, [ + agentId, + ]); + return res.rows[0] ? mapRow(res.rows[0]) : null; + } + + async loadAll(): Promise> { + const res = await this.pool.query(`SELECT * FROM ${BREAKERS}`); + const states = new Map(); + for (const row of res.rows) { + const state = mapRow(row); + states.set(state.agent_id, state); + } + return states; + } + + async persist(state: CircuitBreakerState, _instanceId: string): Promise { + await this.pool.query( + `INSERT INTO ${BREAKERS} + (agent_id, state, failure_count, success_count, last_failure_time, last_state_change, half_open_calls, backoff_multiplier) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8) + ON CONFLICT (agent_id) DO UPDATE SET + state = EXCLUDED.state, + failure_count = EXCLUDED.failure_count, + success_count = EXCLUDED.success_count, + last_failure_time = EXCLUDED.last_failure_time, + last_state_change = EXCLUDED.last_state_change, + half_open_calls = EXCLUDED.half_open_calls, + backoff_multiplier = EXCLUDED.backoff_multiplier`, + [ + state.agent_id, + state.state, + state.failure_count, + state.success_count, + state.last_failure_time ?? null, + state.last_state_change, + state.half_open_calls, + state.backoff_multiplier, + ], + ); + } + + async acquireLeadership(instanceId: string, leaseMs: number): Promise { + const now = Date.now(); + const leaseExpiresAt = now + leaseMs; + const client = await this.pool.connect(); + try { + await client.query('BEGIN'); + await client.query( + `INSERT INTO ${LEADER} (id, leader_id, last_heartbeat, lease_expires_at) + VALUES ($1,$2,$3,$4) ON CONFLICT (id) DO NOTHING`, + [LEADER_ID, instanceId, now, leaseExpiresAt], + ); + const res = await client.query<{ leader_id: string; lease_expires_at: string }>( + `SELECT leader_id, lease_expires_at FROM ${LEADER} WHERE id = $1 FOR UPDATE`, + [LEADER_ID], + ); + const row = res.rows[0]; + const currentLease = row ? Number(row.lease_expires_at) : 0; + + let result: LeaderState; + if (!row || row.leader_id === instanceId || now > currentLease) { + await client.query( + `UPDATE ${LEADER} SET leader_id = $2, last_heartbeat = $3, lease_expires_at = $4 WHERE id = $1`, + [LEADER_ID, instanceId, now, leaseExpiresAt], + ); + result = { isLeader: true, leaderId: instanceId, lastHeartbeat: now, leaseExpiresAt }; + } else { + result = { + isLeader: false, + leaderId: row.leader_id, + lastHeartbeat: now, + leaseExpiresAt: currentLease, + }; + } + await client.query('COMMIT'); + return result; + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + } +} diff --git a/packages/postgres/src/index.ts b/packages/postgres/src/index.ts new file mode 100644 index 0000000..b0eb8ed --- /dev/null +++ b/packages/postgres/src/index.ts @@ -0,0 +1,3 @@ +export { PostgresBreakerStore } from './breaker.store.js'; +export { ensureSchema, SCHEMA_SQL } from './schema.js'; +export { PostgresSessionStore } from './session.store.js'; diff --git a/packages/postgres/src/postgres.test.ts b/packages/postgres/src/postgres.test.ts new file mode 100644 index 0000000..6cc0eb8 --- /dev/null +++ b/packages/postgres/src/postgres.test.ts @@ -0,0 +1,111 @@ +import type { Pool } from 'pg'; +import { describe, expect, it } from 'vitest'; +import { PostgresBreakerStore } from './breaker.store.js'; +import { ensureSchema } from './schema.js'; +import { PostgresSessionStore } from './session.store.js'; + +type QueryResult = { rows: unknown[]; rowCount?: number }; +type Responder = (sql: string, params?: unknown[]) => QueryResult; + +// Mock pg Pool — records SQL and returns canned rows. Real behaviour (jsonb, +// FOR UPDATE, timestamptz) needs a live Postgres; see the package README. +class MockPool { + calls: string[] = []; + constructor(private readonly responder: Responder) {} + async query(sql: string, params?: unknown[]): Promise { + this.calls.push(sql); + return this.responder(sql, params); + } + async connect() { + return { + query: async (sql: string, params?: unknown[]) => { + this.calls.push(sql); + return this.responder(sql, params); + }, + release() {}, + }; + } +} + +describe('ensureSchema', () => { + it('runs the idempotent DDL', async () => { + const pool = new MockPool(() => ({ rows: [] })); + await ensureSchema(pool as unknown as Pool); + expect( + pool.calls.some((s) => s.includes('CREATE TABLE IF NOT EXISTS agent_mesh_sessions')), + ).toBe(true); + }); +}); + +describe('PostgresSessionStore', () => { + it('maps a row to a SessionRecord', async () => { + const ttl = new Date(Date.now() + 60_000); + const pool = new MockPool((sql) => { + if (sql.startsWith('SELECT * FROM agent_mesh_sessions WHERE session_id')) { + return { + rows: [ + { + session_id: 's1', + user_id: 'u1', + employee_id: 'e1', + status: 'active', + active_agent: 'a1', + turn_history: [ + { role: 'user', content: 'hi', timestamp: '2026-01-01T00:00:00.000Z' }, + ], + workflow_state: { step: 1 }, + metadata: { orgId: 'org-1' }, + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-01T00:00:00.000Z', + ttl, + }, + ], + }; + } + return { rows: [] }; + }); + const store = new PostgresSessionStore(pool as unknown as Pool); + const rec = await store.get('s1'); + expect(rec?.session_id).toBe('s1'); + expect(rec?.turn_history).toHaveLength(1); + expect(rec?.metadata).toEqual({ orgId: 'org-1' }); + expect(rec?.ttl).toBe(ttl); + }); +}); + +describe('PostgresBreakerStore', () => { + it('loads + number-coerces breaker state', async () => { + const pool = new MockPool(() => ({ + rows: [ + { + agent_id: 'a1', + state: 'HALF_OPEN', + failure_count: '2', + success_count: '1', + last_failure_time: '1700000000000', + last_state_change: '1700000000001', + half_open_calls: '0', + backoff_multiplier: '2', + }, + ], + })); + const store = new PostgresBreakerStore(pool as unknown as Pool); + const state = await store.load('a1'); + expect(state?.state).toBe('HALF_OPEN'); + expect(state?.failure_count).toBe(2); + expect(state?.last_failure_time).toBe(1_700_000_000_000); + }); + + it('claims leadership when the lease is expired', async () => { + const pool = new MockPool((sql) => { + if (sql.includes('SELECT leader_id')) { + return { rows: [{ leader_id: 'other', lease_expires_at: '1' }] }; // long-expired + } + return { rows: [] }; + }); + const store = new PostgresBreakerStore(pool as unknown as Pool); + const res = await store.acquireLeadership('inst-1', 30_000); + expect(res.isLeader).toBe(true); + expect(res.leaderId).toBe('inst-1'); + }); +}); diff --git a/packages/postgres/src/schema.ts b/packages/postgres/src/schema.ts new file mode 100644 index 0000000..63789cc --- /dev/null +++ b/packages/postgres/src/schema.ts @@ -0,0 +1,47 @@ +import type { Pool } from 'pg'; + +/** + * DDL for the Postgres-backed session + circuit-breaker stores. Idempotent — + * safe to run on boot. Table names are fixed; wrap in your own schema/search_path + * if you need isolation. + */ +export const SCHEMA_SQL = ` +CREATE TABLE IF NOT EXISTS agent_mesh_sessions ( + session_id text PRIMARY KEY, + user_id text NOT NULL, + employee_id text NOT NULL DEFAULT '', + status text NOT NULL, + active_agent text NOT NULL DEFAULT '', + turn_history jsonb NOT NULL DEFAULT '[]'::jsonb, + workflow_state jsonb NOT NULL DEFAULT '{}'::jsonb, + metadata jsonb, + created_at text NOT NULL, + updated_at text NOT NULL, + ttl timestamptz +); +CREATE INDEX IF NOT EXISTS idx_agent_mesh_sessions_user_active + ON agent_mesh_sessions (user_id, status, ttl); + +CREATE TABLE IF NOT EXISTS agent_mesh_circuit_breakers ( + agent_id text PRIMARY KEY, + state text NOT NULL, + failure_count integer NOT NULL DEFAULT 0, + success_count integer NOT NULL DEFAULT 0, + last_failure_time bigint, + last_state_change bigint NOT NULL, + half_open_calls integer NOT NULL DEFAULT 0, + backoff_multiplier double precision NOT NULL DEFAULT 1 +); + +CREATE TABLE IF NOT EXISTS agent_mesh_leader ( + id text PRIMARY KEY, + leader_id text NOT NULL, + last_heartbeat bigint NOT NULL, + lease_expires_at bigint NOT NULL +); +`; + +/** Run the idempotent DDL. Call once on boot before using the stores. */ +export async function ensureSchema(pool: Pool): Promise { + await pool.query(SCHEMA_SQL); +} diff --git a/packages/postgres/src/session.store.ts b/packages/postgres/src/session.store.ts new file mode 100644 index 0000000..c1918f0 --- /dev/null +++ b/packages/postgres/src/session.store.ts @@ -0,0 +1,145 @@ +import type { SessionRecord, SessionStatus, SessionStore, TurnEntry } from '@reaatech/agent-mesh'; +import type { Pool } from 'pg'; + +const TABLE = 'agent_mesh_sessions'; + +interface SessionRow { + session_id: string; + user_id: string; + employee_id: string; + status: SessionStatus; + active_agent: string; + turn_history: TurnEntry[]; + workflow_state: Record; + metadata: Record | null; + created_at: string; + updated_at: string; + ttl: Date | null; +} + +function mapRow(row: SessionRow): SessionRecord { + return { + session_id: row.session_id, + user_id: row.user_id, + employee_id: row.employee_id, + status: row.status, + active_agent: row.active_agent, + turn_history: row.turn_history ?? [], + workflow_state: row.workflow_state ?? {}, + metadata: row.metadata ?? undefined, + created_at: row.created_at, + updated_at: row.updated_at, + ttl: row.ttl ?? new Date(), + }; +} + +/** Postgres-backed {@link SessionStore}. Pass an existing `pg` Pool. Run `ensureSchema` first. */ +export class PostgresSessionStore implements SessionStore { + constructor(private readonly pool: Pool) {} + + async create(record: SessionRecord): Promise { + await this.pool.query( + `INSERT INTO ${TABLE} + (session_id, user_id, employee_id, status, active_agent, turn_history, workflow_state, metadata, created_at, updated_at, ttl) + VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7::jsonb,$8::jsonb,$9,$10,$11)`, + [ + record.session_id, + record.user_id, + record.employee_id, + record.status, + record.active_agent, + JSON.stringify(record.turn_history), + JSON.stringify(record.workflow_state), + record.metadata ? JSON.stringify(record.metadata) : null, + record.created_at, + record.updated_at, + record.ttl, + ], + ); + } + + async get(sessionId: string): Promise { + const res = await this.pool.query(`SELECT * FROM ${TABLE} WHERE session_id = $1`, [ + sessionId, + ]); + return res.rows[0] ? mapRow(res.rows[0]) : null; + } + + async getActiveByUser(userId: string): Promise { + const res = await this.pool.query( + `SELECT * FROM ${TABLE} + WHERE user_id = $1 AND status = 'active' AND ttl > now() + ORDER BY updated_at DESC LIMIT 1`, + [userId], + ); + return res.rows[0] ? mapRow(res.rows[0]) : null; + } + + async appendTurn( + sessionId: string, + turn: TurnEntry, + opts: { maxTurns: number; ttlMs: number }, + ): Promise { + const client = await this.pool.connect(); + try { + await client.query('BEGIN'); + const res = await client.query<{ turn_history: TurnEntry[] }>( + `SELECT turn_history FROM ${TABLE} WHERE session_id = $1 FOR UPDATE`, + [sessionId], + ); + if (res.rowCount === 0) { + await client.query('ROLLBACK'); + throw new Error(`Session ${sessionId} not found`); + } + const current = (res.rows[0]?.turn_history ?? []).slice(-opts.maxTurns + 1); + const next = [...current, turn]; + await client.query( + `UPDATE ${TABLE} SET turn_history = $2::jsonb, updated_at = $3, ttl = $4 WHERE session_id = $1`, + [ + sessionId, + JSON.stringify(next), + new Date().toISOString(), + new Date(Date.now() + opts.ttlMs), + ], + ); + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + } + + async updateWorkflowState( + sessionId: string, + workflowState: Record, + ): Promise { + await this.pool.query( + `UPDATE ${TABLE} SET workflow_state = $2::jsonb, updated_at = $3 WHERE session_id = $1`, + [sessionId, JSON.stringify(workflowState), new Date().toISOString()], + ); + } + + async seed( + sessionId: string, + data: { turn_history: TurnEntry[]; workflow_state: Record }, + ): Promise { + await this.pool.query( + `UPDATE ${TABLE} SET turn_history = $2::jsonb, workflow_state = $3::jsonb, updated_at = $4 WHERE session_id = $1`, + [ + sessionId, + JSON.stringify(data.turn_history), + JSON.stringify(data.workflow_state), + new Date().toISOString(), + ], + ); + } + + async close(sessionId: string, status: Exclude): Promise { + await this.pool.query( + `UPDATE ${TABLE} SET status = $2, updated_at = $3, ttl = NULL WHERE session_id = $1`, + [sessionId, status, new Date().toISOString()], + ); + } +} diff --git a/packages/postgres/tsconfig.json b/packages/postgres/tsconfig.json new file mode 100644 index 0000000..e5df8a6 --- /dev/null +++ b/packages/postgres/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["src/**/*"] +} diff --git a/packages/postgres/vitest.config.ts b/packages/postgres/vitest.config.ts new file mode 100644 index 0000000..037c216 --- /dev/null +++ b/packages/postgres/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: false, + environment: 'node', + env: { + GOOGLE_CLOUD_PROJECT: 'test-project', + API_KEY: 'test-key', + NODE_ENV: 'test', + }, + coverage: { + reporter: ['text', 'json-summary'], + }, + }, +}); diff --git a/packages/redis/README.md b/packages/redis/README.md new file mode 100644 index 0000000..e45b9df --- /dev/null +++ b/packages/redis/README.md @@ -0,0 +1,34 @@ +# @reaatech/agent-mesh-redis + +Redis-backed `SessionStore` and `BreakerStore` adapters for +[agent-mesh](https://github.com/reaatech/agent-mesh) — a drop-in alternative to +the default Firestore backend. + +```bash +pnpm add @reaatech/agent-mesh-redis ioredis +``` + +## Usage + +```ts +import Redis from 'ioredis'; +import { RedisSessionStore, RedisBreakerStore } from '@reaatech/agent-mesh-redis'; +import { setSessionStore } from '@reaatech/agent-mesh-session'; +import { setBreakerStore } from '@reaatech/agent-mesh-utils'; + +const redis = new Redis(process.env.REDIS_URL); + +setSessionStore(new RedisSessionStore(redis)); // { keyPrefix? } — default 'am:' +setBreakerStore(new RedisBreakerStore(redis)); +``` + +- **Sessions** are stored as JSON at `am:session:` with a millisecond TTL; an + `am:user::active` pointer backs `getActiveByUser`. +- **Circuit breakers** are stored at `am:breaker:`; leader election uses a + single `SET NX PX` lease at `am:leader` (a new instance can take over only after + the lease expires). + +`ioredis` is a peer dependency (bring your own client/pool). + +> **Note:** unit-tested against an in-memory fake. Run a smoke test against a live +> Redis (esp. multi-instance leader election) before production use. diff --git a/packages/redis/package.json b/packages/redis/package.json new file mode 100644 index 0000000..547c545 --- /dev/null +++ b/packages/redis/package.json @@ -0,0 +1,51 @@ +{ + "name": "@reaatech/agent-mesh-redis", + "version": "0.1.0", + "type": "module", + "description": "Redis-backed SessionStore and BreakerStore adapters for agent-mesh", + "license": "MIT", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsup src/index.ts --format cjs,esm --dts --clean", + "test": "vitest run", + "test:coverage": "vitest run --coverage", + "clean": "rm -rf dist", + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "ioredis": ">=5" + }, + "dependencies": { + "@reaatech/agent-mesh": "workspace:*" + }, + "devDependencies": { + "@types/node": "^25.6.0", + "ioredis": "^5.4.1", + "tsup": "^8.4.0" + }, + "repository": { + "type": "git", + "url": "https://github.com/reaatech/agent-mesh.git", + "directory": "packages/redis" + }, + "author": "Rick Somers (https://reaatech.com)", + "homepage": "https://github.com/reaatech/agent-mesh/tree/main/packages/redis#readme", + "bugs": { + "url": "https://github.com/reaatech/agent-mesh/issues" + } +} diff --git a/packages/redis/src/breaker.store.ts b/packages/redis/src/breaker.store.ts new file mode 100644 index 0000000..301aaf0 --- /dev/null +++ b/packages/redis/src/breaker.store.ts @@ -0,0 +1,81 @@ +import type { BreakerStore, CircuitBreakerState, LeaderState } from '@reaatech/agent-mesh'; +import type { Redis } from 'ioredis'; + +/** + * Redis-backed {@link BreakerStore}. Leader election uses a single `SET NX PX` + * lease key (the same instance renews it; a new instance can only take over once + * the lease has expired). + */ +export class RedisBreakerStore implements BreakerStore { + private readonly prefix: string; + + constructor( + private readonly redis: Redis, + opts: { keyPrefix?: string } = {}, + ) { + this.prefix = opts.keyPrefix ?? 'am:'; + } + + private breakerKey(agentId: string): string { + return `${this.prefix}breaker:${agentId}`; + } + + private get leaderKey(): string { + return `${this.prefix}leader`; + } + + async load(agentId: string): Promise { + const raw = await this.redis.get(this.breakerKey(agentId)); + return raw ? (JSON.parse(raw) as CircuitBreakerState) : null; + } + + async loadAll(): Promise> { + const states = new Map(); + const match = `${this.prefix}breaker:*`; + let cursor = '0'; + do { + const [next, keys] = await this.redis.scan(cursor, 'MATCH', match, 'COUNT', 100); + cursor = next; + if (keys.length > 0) { + const values = await this.redis.mget(keys); + for (const raw of values) { + if (raw) { + const state = JSON.parse(raw) as CircuitBreakerState; + states.set(state.agent_id, state); + } + } + } + } while (cursor !== '0'); + return states; + } + + async persist(state: CircuitBreakerState, _instanceId: string): Promise { + await this.redis.set(this.breakerKey(state.agent_id), JSON.stringify(state)); + } + + async acquireLeadership(instanceId: string, leaseMs: number): Promise { + const now = Date.now(); + const leaseExpiresAt = now + leaseMs; + + // Try to claim an unheld lease. + const claimed = await this.redis.set(this.leaderKey, instanceId, 'PX', leaseMs, 'NX'); + if (claimed === 'OK') { + return { isLeader: true, leaderId: instanceId, lastHeartbeat: now, leaseExpiresAt }; + } + + // Already held — renew if we are the current holder, else report the holder. + const current = await this.redis.get(this.leaderKey); + if (current === instanceId) { + await this.redis.set(this.leaderKey, instanceId, 'PX', leaseMs); + return { isLeader: true, leaderId: instanceId, lastHeartbeat: now, leaseExpiresAt }; + } + + const pttl = await this.redis.pttl(this.leaderKey); + return { + isLeader: false, + leaderId: current ?? 'unknown', + lastHeartbeat: now, + leaseExpiresAt: pttl > 0 ? now + pttl : now, + }; + } +} diff --git a/packages/redis/src/index.ts b/packages/redis/src/index.ts new file mode 100644 index 0000000..a241d7d --- /dev/null +++ b/packages/redis/src/index.ts @@ -0,0 +1,2 @@ +export { RedisBreakerStore } from './breaker.store.js'; +export { RedisSessionStore } from './session.store.js'; diff --git a/packages/redis/src/redis.test.ts b/packages/redis/src/redis.test.ts new file mode 100644 index 0000000..b7def44 --- /dev/null +++ b/packages/redis/src/redis.test.ts @@ -0,0 +1,102 @@ +import type { CircuitBreakerState, SessionRecord } from '@reaatech/agent-mesh'; +import type { Redis } from 'ioredis'; +import { describe, expect, it } from 'vitest'; +import { RedisBreakerStore } from './breaker.store.js'; +import { RedisSessionStore } from './session.store.js'; + +// Minimal in-memory ioredis fake — enough to exercise the adapters' logic +// (real cross-instance behaviour needs a live server; see package README). +class FakeRedis { + store = new Map(); + // biome-ignore lint/suspicious/noExplicitAny: variadic SET modifiers (PX/NX/KEEPTTL) + async set(key: string, value: string, ...args: any[]): Promise<'OK' | null> { + if (args.includes('NX') && this.store.has(key)) { + return null; + } + this.store.set(key, value); + return 'OK'; + } + async get(key: string): Promise { + return this.store.get(key) ?? null; + } + async del(key: string): Promise { + return this.store.delete(key) ? 1 : 0; + } + async mget(keys: string[]): Promise<(string | null)[]> { + return keys.map((k) => this.store.get(k) ?? null); + } + async pttl(key: string): Promise { + return this.store.has(key) ? 100_000 : -2; + } + async scan(_cursor: string, _m: string, match: string): Promise<[string, string[]]> { + const prefix = match.replace('*', ''); + return ['0', [...this.store.keys()].filter((k) => k.startsWith(prefix))]; + } +} + +function makeSession(overrides: Partial = {}): SessionRecord { + const now = new Date(); + return { + session_id: 's1', + user_id: 'u1', + employee_id: 'e1', + status: 'active', + active_agent: 'a1', + turn_history: [], + workflow_state: {}, + created_at: now.toISOString(), + updated_at: now.toISOString(), + ttl: new Date(now.getTime() + 60_000), + ...overrides, + }; +} + +describe('RedisSessionStore', () => { + it('round-trips a session lifecycle', async () => { + const store = new RedisSessionStore(new FakeRedis() as unknown as Redis); + await store.create(makeSession()); + + const active = await store.getActiveByUser('u1'); + expect(active?.session_id).toBe('s1'); + expect(active?.ttl).toBeInstanceOf(Date); + + await store.appendTurn( + 's1', + { role: 'user', content: 'hi', timestamp: new Date().toISOString() }, + { + maxTurns: 10, + ttlMs: 60_000, + }, + ); + expect((await store.get('s1'))?.turn_history).toHaveLength(1); + + await store.close('s1', 'completed'); + expect((await store.get('s1'))?.status).toBe('completed'); + expect(await store.getActiveByUser('u1')).toBeNull(); + }); +}); + +describe('RedisBreakerStore', () => { + it('persists/loads state and elects a single leader', async () => { + const redis = new FakeRedis() as unknown as Redis; + const store = new RedisBreakerStore(redis); + const state: CircuitBreakerState = { + agent_id: 'a1', + state: 'OPEN', + failure_count: 3, + success_count: 0, + last_state_change: Date.now(), + half_open_calls: 0, + backoff_multiplier: 1, + }; + await store.persist(state, 'inst-1'); + expect((await store.load('a1'))?.state).toBe('OPEN'); + expect((await store.loadAll()).size).toBe(1); + + const first = await store.acquireLeadership('inst-1', 30_000); + expect(first.isLeader).toBe(true); + const second = await store.acquireLeadership('inst-2', 30_000); + expect(second.isLeader).toBe(false); + expect(second.leaderId).toBe('inst-1'); + }); +}); diff --git a/packages/redis/src/session.store.ts b/packages/redis/src/session.store.ts new file mode 100644 index 0000000..a2578d3 --- /dev/null +++ b/packages/redis/src/session.store.ts @@ -0,0 +1,129 @@ +import type { SessionRecord, SessionStatus, SessionStore, TurnEntry } from '@reaatech/agent-mesh'; +import type { Redis } from 'ioredis'; + +/** Redis-backed {@link SessionStore}. Pass an existing ioredis client. */ +export class RedisSessionStore implements SessionStore { + private readonly prefix: string; + + constructor( + private readonly redis: Redis, + opts: { keyPrefix?: string } = {}, + ) { + this.prefix = opts.keyPrefix ?? 'am:'; + } + + private sessionKey(id: string): string { + return `${this.prefix}session:${id}`; + } + + private userActiveKey(userId: string): string { + return `${this.prefix}user:${userId}:active`; + } + + private static serialize(record: SessionRecord): string { + return JSON.stringify({ ...record, ttl: record.ttl.toISOString() }); + } + + private static deserialize(raw: string): SessionRecord { + const obj = JSON.parse(raw) as Omit & { ttl: string }; + return { ...obj, ttl: new Date(obj.ttl) }; + } + + private ttlMs(record: SessionRecord): number { + return Math.max(0, record.ttl.getTime() - Date.now()); + } + + async create(record: SessionRecord): Promise { + const ttlMs = this.ttlMs(record); + const value = RedisSessionStore.serialize(record); + if (ttlMs > 0) { + await this.redis.set(this.sessionKey(record.session_id), value, 'PX', ttlMs); + await this.redis.set(this.userActiveKey(record.user_id), record.session_id, 'PX', ttlMs); + } else { + await this.redis.set(this.sessionKey(record.session_id), value); + await this.redis.set(this.userActiveKey(record.user_id), record.session_id); + } + } + + async get(sessionId: string): Promise { + const raw = await this.redis.get(this.sessionKey(sessionId)); + return raw ? RedisSessionStore.deserialize(raw) : null; + } + + async getActiveByUser(userId: string): Promise { + const id = await this.redis.get(this.userActiveKey(userId)); + if (!id) { + return null; + } + const record = await this.get(id); + return record && record.status === 'active' ? record : null; + } + + async appendTurn( + sessionId: string, + turn: TurnEntry, + opts: { maxTurns: number; ttlMs: number }, + ): Promise { + const record = await this.get(sessionId); + if (!record) { + throw new Error(`Session ${sessionId} not found`); + } + record.turn_history = [...record.turn_history.slice(-opts.maxTurns + 1), turn]; + record.updated_at = new Date().toISOString(); + record.ttl = new Date(Date.now() + opts.ttlMs); + await this.redis.set( + this.sessionKey(sessionId), + RedisSessionStore.serialize(record), + 'PX', + opts.ttlMs, + ); + await this.redis.set(this.userActiveKey(record.user_id), sessionId, 'PX', opts.ttlMs); + } + + async updateWorkflowState( + sessionId: string, + workflowState: Record, + ): Promise { + const record = await this.get(sessionId); + if (!record) { + return; + } + record.workflow_state = workflowState; + record.updated_at = new Date().toISOString(); + await this.redis.set( + this.sessionKey(sessionId), + RedisSessionStore.serialize(record), + 'KEEPTTL', + ); + } + + async seed( + sessionId: string, + data: { turn_history: TurnEntry[]; workflow_state: Record }, + ): Promise { + const record = await this.get(sessionId); + if (!record) { + return; + } + record.turn_history = data.turn_history; + record.workflow_state = data.workflow_state; + record.updated_at = new Date().toISOString(); + await this.redis.set( + this.sessionKey(sessionId), + RedisSessionStore.serialize(record), + 'KEEPTTL', + ); + } + + async close(sessionId: string, status: Exclude): Promise { + const record = await this.get(sessionId); + if (!record) { + return; + } + record.status = status; + record.updated_at = new Date().toISOString(); + // Persist the closed record (drop TTL), and clear the user's active pointer. + await this.redis.set(this.sessionKey(sessionId), RedisSessionStore.serialize(record)); + await this.redis.del(this.userActiveKey(record.user_id)); + } +} diff --git a/packages/redis/tsconfig.json b/packages/redis/tsconfig.json new file mode 100644 index 0000000..e5df8a6 --- /dev/null +++ b/packages/redis/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["src/**/*"] +} diff --git a/packages/redis/vitest.config.ts b/packages/redis/vitest.config.ts new file mode 100644 index 0000000..037c216 --- /dev/null +++ b/packages/redis/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: false, + environment: 'node', + env: { + GOOGLE_CLOUD_PROJECT: 'test-project', + API_KEY: 'test-key', + NODE_ENV: 'test', + }, + coverage: { + reporter: ['text', 'json-summary'], + }, + }, +}); diff --git a/packages/registry/src/types.ts b/packages/registry/src/types.ts index 7599871..adcb24c 100644 --- a/packages/registry/src/types.ts +++ b/packages/registry/src/types.ts @@ -1,65 +1,11 @@ -import { PRIVATE_IP_RANGES } from '@reaatech/agent-mesh'; +import { AgentConfigSchema } from '@reaatech/agent-mesh'; import { z } from 'zod'; -function isSsrfSafeUrl(url: string): boolean { - try { - const parsed = new URL(url); - - if (!['http:', 'https:'].includes(parsed.protocol)) { - return false; - } - - const hostname = parsed.hostname.toLowerCase(); - - for (const pattern of PRIVATE_IP_RANGES) { - if (pattern.test(hostname)) { - return false; - } - } - - return true; - } catch { - return false; - } -} - -const ssrfSafeUrl = z.string().refine((url) => isSsrfSafeUrl(url), { - message: - 'Endpoint URL is not allowed: localhost and private IP ranges are rejected for SSRF protection.', -}); - -export const AgentConfigSchema = z.object({ - agent_id: z - .string() - .min(1, 'agent_id is required') - .regex(/^[a-z0-9-]+$/, 'agent_id must be lowercase alphanumeric with hyphens'), - - display_name: z.string().min(1, 'display_name is required').max(200), - - description: z - .string() - .min(1, 'description is required') - .max(5000, 'description too long (max 5000 chars)'), - - endpoint: ssrfSafeUrl, - - type: z.literal('mcp'), - - is_default: z.boolean().default(false), - - confidence_threshold: z.number().min(0).max(1).default(0), - - clarification_required: z.boolean().default(false), - - clarification_context: z.string().max(500).optional(), - - examples: z - .array(z.string().min(1).max(500)) - .min(1, 'at least one example is required') - .max(20, 'maximum 20 examples allowed'), -}); - -export type AgentConfig = z.infer; +export type { AgentConfig } from '@reaatech/agent-mesh'; +// AgentConfig is defined once, in core (`@reaatech/agent-mesh`). It is re-exported +// here for back-compat with consumers that import it from the registry package. +// The registry package owns only the AgentRegistry array schema + its invariants. +export { AgentConfigSchema } from '@reaatech/agent-mesh'; export const AgentRegistrySchema = z .array(AgentConfigSchema) diff --git a/packages/router/README.md b/packages/router/README.md index fae5639..3ef6eb0 100644 --- a/packages/router/README.md +++ b/packages/router/README.md @@ -1,5 +1,9 @@ # @reaatech/agent-mesh-router +> Dispatch over **MCP** *or* **in-process** (`type: 'inprocess'`, via +> `registerInProcessAgent`). A `metadata` passthrough carries host context (e.g. a +> tenant `orgId`) into the `ContextPacket`. + [![npm version](https://img.shields.io/npm/v/@reaatech/agent-mesh-router.svg)](https://www.npmjs.com/package/@reaatech/agent-mesh-router) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/reaatech/agent-mesh/blob/main/LICENSE) [![CI](https://github.com/reaatech/agent-mesh/actions/workflows/ci.yml/badge.svg)](https://github.com/reaatech/agent-mesh/actions/workflows/ci.yml) diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts index 5baa9e2..1b53ea4 100644 --- a/packages/router/src/index.ts +++ b/packages/router/src/index.ts @@ -1,3 +1,11 @@ +export { + dispatchInProcess, + hasInProcessAgent, + type InProcessHandler, + registerInProcessAgent, + resetInProcessAgents, + unregisterInProcessAgent, +} from './inprocess.transport.js'; export { McpClient, mcpClientFactory } from './mcp.client.js'; export { buildTurnEntry, diff --git a/packages/router/src/inprocess.transport.ts b/packages/router/src/inprocess.transport.ts new file mode 100644 index 0000000..bb298ec --- /dev/null +++ b/packages/router/src/inprocess.transport.ts @@ -0,0 +1,46 @@ +import type { AgentConfig, AgentResponse, ContextPacket } from '@reaatech/agent-mesh'; + +/** + * A locally-registered agent handler. Receives the same `ContextPacket` the MCP + * transport would send and returns an `AgentResponse` — but runs **in-process**, + * with no HTTP hop. The embedding host reads `context.metadata` (e.g. a tenant + * `orgId`) to resolve its own per-call dependencies. + */ +export type InProcessHandler = ( + context: ContextPacket, + agent: AgentConfig, +) => Promise | AgentResponse; + +const handlers = new Map(); + +/** Register an in-process handler for an agent (`type: 'inprocess'`). */ +export function registerInProcessAgent(agentId: string, handler: InProcessHandler): void { + handlers.set(agentId, handler); +} + +/** Remove a registered in-process handler. */ +export function unregisterInProcessAgent(agentId: string): void { + handlers.delete(agentId); +} + +/** Whether an in-process handler is registered for the agent. */ +export function hasInProcessAgent(agentId: string): boolean { + return handlers.has(agentId); +} + +/** Clear all in-process handlers. Primarily for tests. */ +export function resetInProcessAgents(): void { + handlers.clear(); +} + +/** Dispatch a context packet to the agent's in-process handler. */ +export async function dispatchInProcess( + agent: AgentConfig, + context: ContextPacket, +): Promise { + const handler = handlers.get(agent.agent_id); + if (!handler) { + throw new Error(`No in-process handler registered for agent ${agent.agent_id}`); + } + return handler(context, agent); +} diff --git a/packages/router/src/mcp.client.ts b/packages/router/src/mcp.client.ts index af056e7..bfa58f9 100644 --- a/packages/router/src/mcp.client.ts +++ b/packages/router/src/mcp.client.ts @@ -46,6 +46,11 @@ export class McpClient { } if (this.connectionPool.length < MAX_POOL_SIZE) { + if (!this.agent.endpoint) { + throw new Error( + `Agent ${this.agent.agent_id} has no endpoint (mcp transport requires one)`, + ); + } const transport = new StreamableHTTPClientTransport(new URL(this.agent.endpoint)); const client = new Client({ name: 'agent-mesh-orchestrator', diff --git a/packages/router/src/router.service.ts b/packages/router/src/router.service.ts index 926363a..95aee6a 100644 --- a/packages/router/src/router.service.ts +++ b/packages/router/src/router.service.ts @@ -10,6 +10,7 @@ import { } from '@reaatech/agent-mesh-observability'; import type { AgentConfig } from '@reaatech/agent-mesh-registry'; import { circuitBreaker } from '@reaatech/agent-mesh-utils'; +import { dispatchInProcess } from './inprocess.transport.js'; import { mcpClientFactory } from './mcp.client.js'; export async function dispatchToAgent( @@ -24,6 +25,7 @@ export async function dispatchToAgent( detectedLanguage: string; turnHistory: TurnEntry[]; workflowState: Record; + metadata?: Record; }, ): Promise { const start = Date.now(); @@ -45,12 +47,15 @@ export async function dispatchToAgent( detected_language: input.detectedLanguage, turn_history: input.turnHistory, workflow_state: input.workflowState, + metadata: input.metadata, }; - const client = mcpClientFactory.getClient(agent); - try { - const response = await client.sendMessage(context); + // Route by transport: in-process handler (no HTTP hop) vs pooled MCP client. + const response = + agent.type === 'inprocess' + ? await dispatchInProcess(agent, context) + : await mcpClientFactory.getClient(agent).sendMessage(context); if (env.ENABLE_CIRCUIT_BREAKER) { circuitBreaker.recordSuccess(agent.agent_id); } diff --git a/packages/router/src/router.test.ts b/packages/router/src/router.test.ts index c710acb..2edcf01 100644 --- a/packages/router/src/router.test.ts +++ b/packages/router/src/router.test.ts @@ -1,5 +1,13 @@ -import { describe, expect, it } from 'vitest'; -import { buildTurnEntry, mcpClientFactory, shouldCloseSession } from './index.js'; +import type { AgentConfig } from '@reaatech/agent-mesh-registry'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + buildTurnEntry, + dispatchToAgent, + mcpClientFactory, + registerInProcessAgent, + resetInProcessAgents, + shouldCloseSession, +} from './index.js'; describe('@reaatech/agent-mesh-router', () => { it('should export client factory', () => { @@ -19,3 +27,41 @@ describe('@reaatech/agent-mesh-router', () => { expect(shouldCloseSession({ content: 'cont', workflow_complete: false })).toBe(false); }); }); + +describe('in-process transport', () => { + afterEach(() => resetInProcessAgents()); + + it('dispatches to a registered in-process handler (no HTTP) with metadata', async () => { + const agent = { + agent_id: 'local-agent', + display_name: 'Local', + description: 'in-process test agent', + type: 'inprocess', + is_default: false, + confidence_threshold: 0, + clarification_required: false, + examples: ['hi'], + } as AgentConfig; + + registerInProcessAgent('local-agent', (ctx) => ({ + content: `handled ${ctx.raw_input} for org ${ctx.metadata?.orgId}`, + workflow_complete: true, + })); + + const res = await dispatchToAgent(agent, { + sessionId: '00000000-0000-0000-0000-000000000000', + employeeId: 'emp-1', + displayName: 'User', + rawInput: 'ping', + intentSummary: 'ping', + entities: {}, + detectedLanguage: 'en', + turnHistory: [], + workflowState: {}, + metadata: { orgId: 'org-123' }, + }); + + expect(res.content).toBe('handled ping for org org-123'); + expect(res.workflow_complete).toBe(true); + }); +}); diff --git a/packages/session/README.md b/packages/session/README.md index 151ba88..08ec3d6 100644 --- a/packages/session/README.md +++ b/packages/session/README.md @@ -1,5 +1,8 @@ # @reaatech/agent-mesh-session +> **Pluggable persistence** via `SessionStore` + `setSessionStore` — Firestore by +> default, plus `InMemorySessionStore` and the Postgres/Redis adapter packages. + [![npm version](https://img.shields.io/npm/v/@reaatech/agent-mesh-session.svg)](https://www.npmjs.com/package/@reaatech/agent-mesh-session) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/reaatech/agent-mesh/blob/main/LICENSE) [![CI](https://github.com/reaatech/agent-mesh/actions/workflows/ci.yml/badge.svg)](https://github.com/reaatech/agent-mesh/actions/workflows/ci.yml) diff --git a/packages/utils/README.md b/packages/utils/README.md index e6a4e2e..32cf7b5 100644 --- a/packages/utils/README.md +++ b/packages/utils/README.md @@ -1,5 +1,8 @@ # @reaatech/agent-mesh-utils +> **Pluggable breaker persistence** via `BreakerStore` + `setBreakerStore` — +> Firestore by default, plus `InMemoryBreakerStore` and the Postgres/Redis adapters. + [![npm version](https://img.shields.io/npm/v/@reaatech/agent-mesh-utils.svg)](https://www.npmjs.com/package/@reaatech/agent-mesh-utils) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/reaatech/agent-mesh/blob/main/LICENSE) [![CI](https://img.shields.io/github/actions/workflow/status/reaatech/agent-mesh/ci.yml?branch=main&label=CI)](https://github.com/reaatech/agent-mesh/actions/workflows/ci.yml) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e430e73..d1d8acb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -317,6 +317,41 @@ importers: specifier: ^8.4.0 version: 8.5.1(postcss@8.5.16)(typescript@6.0.3)(yaml@2.9.0) + packages/postgres: + dependencies: + '@reaatech/agent-mesh': + specifier: workspace:* + version: link:../core + devDependencies: + '@types/node': + specifier: ^25.6.0 + version: 25.8.0 + '@types/pg': + specifier: ^8.11.10 + version: 8.20.0 + pg: + specifier: ^8.13.1 + version: 8.22.0 + tsup: + specifier: ^8.4.0 + version: 8.5.1(postcss@8.5.16)(typescript@6.0.3)(yaml@2.9.0) + + packages/redis: + dependencies: + '@reaatech/agent-mesh': + specifier: workspace:* + version: link:../core + devDependencies: + '@types/node': + specifier: ^25.6.0 + version: 25.8.0 + ioredis: + specifier: ^5.4.1 + version: 5.11.1 + tsup: + specifier: ^8.4.0 + version: 8.5.1(postcss@8.5.16)(typescript@6.0.3)(yaml@2.9.0) + packages/registry: dependencies: '@reaatech/agent-mesh': @@ -802,6 +837,9 @@ packages: '@types/node': optional: true + '@ioredis/commands@1.10.0': + resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -1369,6 +1407,9 @@ packages: '@types/node@25.8.0': resolution: {integrity: sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==} + '@types/pg@8.20.0': + resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} + '@types/qs@6.15.1': resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} @@ -1588,6 +1629,10 @@ packages: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} + cluster-key-slot@1.1.1: + resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==} + engines: {node: '>=0.10.0'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1683,6 +1728,10 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -2079,6 +2128,10 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ioredis@5.11.1: + resolution: {integrity: sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==} + engines: {node: '>=12.22.0'} + ip-address@10.2.0: resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} engines: {node: '>= 12'} @@ -2488,6 +2541,40 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pg-cloudflare@1.4.0: + resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} + + pg-connection-string@2.14.0: + resolution: {integrity: sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.14.0: + resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.15.0: + resolution: {integrity: sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.22.0: + resolution: {integrity: sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2536,6 +2623,22 @@ packages: resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + prettier@2.8.8: resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} engines: {node: '>=10.13.0'} @@ -2587,6 +2690,14 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -2712,6 +2823,10 @@ packages: spawndamnit@3.0.1: resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} @@ -2721,6 +2836,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -3039,6 +3157,10 @@ packages: utf-8-validate: optional: true + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -3479,6 +3601,8 @@ snapshots: optionalDependencies: '@types/node': 25.8.0 + '@ioredis/commands@1.10.0': {} + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -4052,6 +4176,12 @@ snapshots: dependencies: undici-types: 7.24.6 + '@types/pg@8.20.0': + dependencies: + '@types/node': 25.8.0 + pg-protocol: 1.15.0 + pg-types: 2.2.0 + '@types/qs@6.15.1': {} '@types/range-parser@1.2.7': {} @@ -4273,6 +4403,8 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + cluster-key-slot@1.1.1: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -4341,6 +4473,8 @@ snapshots: delayed-stream@1.0.0: {} + denque@2.1.0: {} + depd@2.0.0: {} detect-indent@6.1.0: {} @@ -4882,6 +5016,18 @@ snapshots: inherits@2.0.4: {} + ioredis@5.11.1: + dependencies: + '@ioredis/commands': 1.10.0 + cluster-key-slot: 1.1.1 + debug: 4.4.3 + denque: 2.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + ip-address@10.2.0: {} ipaddr.js@1.9.1: {} @@ -5210,6 +5356,41 @@ snapshots: pathe@2.0.3: {} + pg-cloudflare@1.4.0: + optional: true + + pg-connection-string@2.14.0: {} + + pg-int8@1.0.1: {} + + pg-pool@3.14.0(pg@8.22.0): + dependencies: + pg: 8.22.0 + + pg-protocol@1.15.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.22.0: + dependencies: + pg-connection-string: 2.14.0 + pg-pool: 3.14.0(pg@8.22.0) + pg-protocol: 1.15.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.4.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -5241,6 +5422,16 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + prettier@2.8.8: {} proto3-json-serializer@3.0.4: @@ -5292,6 +5483,12 @@ snapshots: readdirp@4.1.2: {} + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -5477,12 +5674,16 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + split2@4.2.0: {} + sprintf-js@1.0.3: {} stack-trace@0.0.10: {} stackback@0.0.2: {} + standard-as-callback@2.1.0: {} + statuses@2.0.2: {} std-env@4.1.0: {} @@ -5777,6 +5978,8 @@ snapshots: ws@8.21.0: {} + xtend@4.0.2: {} + y18n@5.0.8: {} yaml@2.9.0: {}