@consensus-tools/universal 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +451 -0
  3. package/dist/__tests__/defaults.test.d.ts +2 -0
  4. package/dist/__tests__/defaults.test.d.ts.map +1 -0
  5. package/dist/__tests__/defaults.test.js +55 -0
  6. package/dist/__tests__/defaults.test.js.map +1 -0
  7. package/dist/__tests__/fail-policy.test.d.ts +2 -0
  8. package/dist/__tests__/fail-policy.test.d.ts.map +1 -0
  9. package/dist/__tests__/fail-policy.test.js +80 -0
  10. package/dist/__tests__/fail-policy.test.js.map +1 -0
  11. package/dist/__tests__/frameworks.test.d.ts +2 -0
  12. package/dist/__tests__/frameworks.test.d.ts.map +1 -0
  13. package/dist/__tests__/frameworks.test.js +86 -0
  14. package/dist/__tests__/frameworks.test.js.map +1 -0
  15. package/dist/__tests__/logger.test.d.ts +2 -0
  16. package/dist/__tests__/logger.test.d.ts.map +1 -0
  17. package/dist/__tests__/logger.test.js +77 -0
  18. package/dist/__tests__/logger.test.js.map +1 -0
  19. package/dist/__tests__/resolve.test.d.ts +2 -0
  20. package/dist/__tests__/resolve.test.d.ts.map +1 -0
  21. package/dist/__tests__/resolve.test.js +71 -0
  22. package/dist/__tests__/resolve.test.js.map +1 -0
  23. package/dist/__tests__/wrap.test.d.ts +2 -0
  24. package/dist/__tests__/wrap.test.d.ts.map +1 -0
  25. package/dist/__tests__/wrap.test.js +90 -0
  26. package/dist/__tests__/wrap.test.js.map +1 -0
  27. package/dist/defaults.d.ts +20 -0
  28. package/dist/defaults.d.ts.map +1 -0
  29. package/dist/defaults.js +48 -0
  30. package/dist/defaults.js.map +1 -0
  31. package/dist/errors.d.ts +23 -0
  32. package/dist/errors.d.ts.map +1 -0
  33. package/dist/errors.js +31 -0
  34. package/dist/errors.js.map +1 -0
  35. package/dist/index.d.ts +38 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +239 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/logger.d.ts +12 -0
  40. package/dist/logger.d.ts.map +1 -0
  41. package/dist/logger.js +55 -0
  42. package/dist/logger.js.map +1 -0
  43. package/dist/resolve.d.ts +9 -0
  44. package/dist/resolve.d.ts.map +1 -0
  45. package/dist/resolve.js +25 -0
  46. package/dist/resolve.js.map +1 -0
  47. package/dist/types.d.ts +35 -0
  48. package/dist/types.d.ts.map +1 -0
  49. package/dist/types.js +2 -0
  50. package/dist/types.js.map +1 -0
  51. package/package.json +82 -0
  52. package/src/__tests__/defaults.test.ts +71 -0
  53. package/src/__tests__/fail-policy.test.ts +107 -0
  54. package/src/__tests__/frameworks.test.ts +106 -0
  55. package/src/__tests__/logger.test.ts +93 -0
  56. package/src/__tests__/resolve.test.ts +80 -0
  57. package/src/__tests__/wrap.test.ts +110 -0
  58. package/src/consensus-llm.test.ts +260 -0
  59. package/src/defaults.ts +124 -0
  60. package/src/errors.ts +35 -0
  61. package/src/index.ts +386 -0
  62. package/src/logger.ts +65 -0
  63. package/src/persona-reviewer-factory.ts +387 -0
  64. package/src/reputation-manager.test.ts +131 -0
  65. package/src/reputation-manager.ts +168 -0
  66. package/src/resolve.ts +30 -0
  67. package/src/risk-tiers.test.ts +36 -0
  68. package/src/risk-tiers.ts +49 -0
  69. package/src/types.ts +127 -0
package/src/index.ts ADDED
@@ -0,0 +1,386 @@
1
+ import { consensus as wrapWithConsensus } from "@consensus-tools/wrapper";
2
+ import type { DecisionResult, ReviewerFn, LifecycleHooks } from "@consensus-tools/wrapper";
3
+ import { createGuardTemplate, GUARD_CONFIGS } from "@consensus-tools/guards";
4
+ import { getPersonasByPack } from "@consensus-tools/personas";
5
+ import { MemoryStorage } from "@consensus-tools/storage";
6
+ import type { IStorage } from "@consensus-tools/storage";
7
+ import type { Wrappable, UniversalConfig, ToolExecutor, LlmDecisionResult } from "./types.js";
8
+ import { resolveWrappable } from "./resolve.js";
9
+ import {
10
+ DEFAULTS,
11
+ DEFAULT_PERSONA_TRIO,
12
+ DEFAULT_PACK,
13
+ DEFAULT_PERSONA_TIMEOUT_MS,
14
+ DEFAULT_RESPAWN_THRESHOLD,
15
+ policyToStrategy,
16
+ resolvePolicyType,
17
+ } from "./defaults.js";
18
+ import { createLogger } from "./logger.js";
19
+ import { ConsensusBlockedError, MissingDependencyError } from "./errors.js";
20
+ import { ReputationManager } from "./reputation-manager.js";
21
+ import { deliberate } from "./persona-reviewer-factory.js";
22
+
23
+ // ── Persona-as-guard templates (regex-only mode) ─────────────────────
24
+
25
+ function createDefaultReviewers(): ReviewerFn[] {
26
+ return DEFAULT_PERSONA_TRIO.map((domain) => {
27
+ const config = GUARD_CONFIGS[domain];
28
+ if (!config) {
29
+ throw new Error(`No guard config for default persona domain: ${domain}`);
30
+ }
31
+ return createGuardTemplate(domain, config).asReviewer();
32
+ });
33
+ }
34
+
35
+ function createReviewersForGuards(guards: string[]): ReviewerFn[] {
36
+ return guards.map((domain) => {
37
+ const config = GUARD_CONFIGS[domain] ?? {
38
+ description: `Custom guard: ${domain}`,
39
+ rules: () => [{ evaluator: domain, vote: "YES" as const, reason: "No rules configured", risk: 0.1 }],
40
+ };
41
+ return createGuardTemplate(domain, config).asReviewer();
42
+ });
43
+ }
44
+
45
+ // ── Storage helpers ──────────────────────────────────────────────────
46
+
47
+ function resolveStorage(storage: "memory" | IStorage): IStorage {
48
+ if (storage === "memory") {
49
+ return new MemoryStorage();
50
+ }
51
+ return storage;
52
+ }
53
+
54
+ function createStorageHooks(store: IStorage): LifecycleHooks {
55
+ return {
56
+ async afterResolve(result: DecisionResult) {
57
+ await store.update((state) => {
58
+ state.audit.push({
59
+ id: `audit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
60
+ at: new Date().toISOString(),
61
+ action: result.action,
62
+ aggregateScore: result.aggregateScore,
63
+ attempt: result.attempt,
64
+ scoresCount: result.scores.length,
65
+ } as any);
66
+ });
67
+ },
68
+ async onBlock(result: DecisionResult) {
69
+ await store.update((state) => {
70
+ state.audit.push({
71
+ id: `audit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
72
+ at: new Date().toISOString(),
73
+ action: "block",
74
+ aggregateScore: result.aggregateScore,
75
+ attempt: result.attempt,
76
+ scoresCount: result.scores.length,
77
+ } as any);
78
+ });
79
+ },
80
+ };
81
+ }
82
+
83
+ function mergeHooks(...hookSets: LifecycleHooks[]): LifecycleHooks {
84
+ return {
85
+ async beforeSubmit(args: unknown[]) {
86
+ for (const h of hookSets) await h.beforeSubmit?.(args);
87
+ },
88
+ async afterResolve(result: DecisionResult) {
89
+ for (const h of hookSets) await h.afterResolve?.(result);
90
+ },
91
+ async onBlock(result: DecisionResult) {
92
+ for (const h of hookSets) await h.onBlock?.(result);
93
+ },
94
+ async onEscalate(result: DecisionResult) {
95
+ for (const h of hookSets) await h.onEscalate?.(result);
96
+ },
97
+ };
98
+ }
99
+
100
+ // ── LLM Persona Mode Setup ──────────────────────────────────────────
101
+
102
+ function createLlmExecutor(
103
+ fn: ToolExecutor,
104
+ config: Required<Pick<UniversalConfig, "policy" | "failPolicy">> & Partial<UniversalConfig>,
105
+ ): ToolExecutor {
106
+ const pack = config.pack ?? DEFAULT_PACK;
107
+ const personas = config.personas ?? getPersonasByPack(pack);
108
+ const policyType = resolvePolicyType(config.policy);
109
+ const timeoutMs = config.personaTimeout ?? DEFAULT_PERSONA_TIMEOUT_MS;
110
+ const respawnThreshold = config.respawnThreshold ?? DEFAULT_RESPAWN_THRESHOLD;
111
+ const mode = config.mode ?? "enforce";
112
+
113
+ // Create reputation manager
114
+ const reputationManager = new ReputationManager(
115
+ personas,
116
+ respawnThreshold,
117
+ config.reputationStore,
118
+ );
119
+
120
+ // Wire respawn events to logger
121
+ reputationManager.setRespawnHandler((event) => {
122
+ if (config.logger !== false) {
123
+ const logFn = typeof config.logger === "function"
124
+ ? config.logger
125
+ : (e: any) => console.debug("[consensus]", e.event, e.data); // eslint-disable-line no-console
126
+ logFn({
127
+ event: "persona.respawned",
128
+ data: {
129
+ oldPersonaId: event.oldPersona.id,
130
+ newPersonaId: event.newPersona.id,
131
+ reputation: event.reputation,
132
+ reason: event.reason,
133
+ },
134
+ timestamp: Date.now(),
135
+ });
136
+ }
137
+ });
138
+
139
+ // Load persisted reputation if store is configured
140
+ if (config.reputationStore) {
141
+ reputationManager.load().catch((err) => {
142
+ if (config.logger !== false) {
143
+ console.warn("[consensus] Failed to load persisted reputation, starting with defaults:", err); // eslint-disable-line no-console
144
+ }
145
+ });
146
+ }
147
+
148
+ // Build the deliberation config
149
+ const deliberateConfig = {
150
+ model: config.model!,
151
+ pack,
152
+ personas: config.personas,
153
+ guards: config.guards ?? DEFAULTS.guards,
154
+ policyType,
155
+ riskTiers: config.riskTiers,
156
+ reputationManager,
157
+ timeoutMs,
158
+ };
159
+
160
+ return async (toolName: string, args: Record<string, unknown>): Promise<unknown> => {
161
+ try {
162
+ const decision: LlmDecisionResult = await deliberate(deliberateConfig, toolName, args);
163
+
164
+ // Fire onDecision callback
165
+ if (config.onDecision) {
166
+ await config.onDecision(decision);
167
+ }
168
+
169
+ // Shadow mode: always allow, just log
170
+ if (mode === "shadow") {
171
+ return fn(toolName, args);
172
+ }
173
+
174
+ // Enforce mode: act on decision
175
+ if (decision.action === "allow") {
176
+ return fn(toolName, args);
177
+ }
178
+
179
+ // Blocked or escalated
180
+ if (config.failPolicy === "closed") {
181
+ const rationales = decision.votes.map((v) => `${v.personaName}: ${v.rationale}`).join("; ");
182
+ throw new ConsensusBlockedError(
183
+ `Consensus ${decision.action}: score ${decision.aggregateScore.toFixed(2)} ` +
184
+ `[${decision.policy}] (${rationales})`,
185
+ );
186
+ }
187
+
188
+ // failPolicy: 'open' — execute anyway
189
+ return fn(toolName, args);
190
+ } catch (err) {
191
+ if (err instanceof ConsensusBlockedError) {
192
+ throw err;
193
+ }
194
+
195
+ // Unexpected error during LLM deliberation — fall back to executing the tool
196
+ const error = err instanceof Error ? err : new Error(String(err));
197
+ config.onError?.(error, { toolName, args });
198
+
199
+ if (config.failPolicy === "closed") {
200
+ throw new ConsensusBlockedError("LLM deliberation failed", error);
201
+ }
202
+
203
+ // failPolicy: 'open' — execute despite error
204
+ return fn(toolName, args);
205
+ }
206
+ };
207
+ }
208
+
209
+ // ── Main Facade ──────────────────────────────────────────────────────
210
+
211
+ export const consensus = {
212
+ /**
213
+ * Wrap any tool executor with consensus governance.
214
+ *
215
+ * Two modes:
216
+ * - **Regex mode** (default): Fast, deterministic pattern-matching guards.
217
+ * `consensus.wrap(executor)` or `consensus.wrap(executor, { policy: "majority" })`
218
+ *
219
+ * - **LLM persona mode**: Multi-model deliberation with reputation tracking.
220
+ * `consensus.wrap(executor, { model: myLlm, policy: "weighted_reputation" })`
221
+ * Activated when `model` is provided in config.
222
+ *
223
+ * @param wrappable - A function, or object with .execute/.invoke/.call
224
+ * @param config - Optional configuration overrides
225
+ * @returns A wrapped function that runs consensus deliberation before allowing execution
226
+ */
227
+ wrap(
228
+ wrappable: Wrappable,
229
+ config?: Partial<UniversalConfig>,
230
+ ): ToolExecutor {
231
+ const fn = resolveWrappable(wrappable);
232
+ const merged = { ...DEFAULTS, ...config };
233
+
234
+ // Production warnings
235
+ const isProduction = typeof process !== "undefined" && process.env?.["NODE_ENV"] === "production";
236
+ if (isProduction && merged.failPolicy === "open") {
237
+ console.warn("[consensus] WARNING: failPolicy 'open' in production — errors will pass through unchecked"); // eslint-disable-line no-console
238
+ }
239
+ if (isProduction && merged.storage === "memory" && !config?.model) {
240
+ console.warn("[consensus] WARNING: storage 'memory' in production — decisions are not persisted"); // eslint-disable-line no-console
241
+ }
242
+
243
+ // ── LLM Persona Mode ──────────────────────────────────────────────
244
+ if (config?.model) {
245
+ return createLlmExecutor(fn, merged);
246
+ }
247
+
248
+ // ── Regex-Only Mode (unchanged from v0.8.0) ───────────────────────
249
+ const strategy = policyToStrategy(merged.policy);
250
+
251
+ const isDefaultGuards =
252
+ Array.isArray(merged.guards) &&
253
+ merged.guards.length === DEFAULTS.guards.length &&
254
+ merged.guards.every((g, i) => g === DEFAULTS.guards[i]);
255
+
256
+ const reviewers: ReviewerFn[] = isDefaultGuards
257
+ ? createDefaultReviewers()
258
+ : createReviewersForGuards(merged.guards);
259
+
260
+ const loggerHooks = createLogger({ logger: merged.logger });
261
+ const store = resolveStorage(merged.storage);
262
+ const storageHooks = createStorageHooks(store);
263
+ const hooks = mergeHooks(loggerHooks, storageHooks);
264
+
265
+ const wrapped = wrapWithConsensus<unknown>({
266
+ name: "universal",
267
+ fn: async (...args: unknown[]) => {
268
+ const [toolName, toolArgs] = args as [string, Record<string, unknown>];
269
+ return fn(toolName, toolArgs);
270
+ },
271
+ reviewers,
272
+ strategy,
273
+ hooks,
274
+ });
275
+
276
+ return async (toolName: string, args: Record<string, unknown>): Promise<unknown> => {
277
+ try {
278
+ const result: DecisionResult<unknown> = await wrapped(toolName, args);
279
+
280
+ if (merged.onDecision) {
281
+ await merged.onDecision(result);
282
+ }
283
+
284
+ if (result.action === "allow") {
285
+ return result.output;
286
+ }
287
+
288
+ if (merged.failPolicy === "closed") {
289
+ throw new ConsensusBlockedError(
290
+ `Consensus ${result.action}: aggregate score ${result.aggregateScore.toFixed(2)} ` +
291
+ `(${result.scores.map((s) => s.rationale ?? "no rationale").join("; ")})`,
292
+ );
293
+ }
294
+
295
+ return fn(toolName, args);
296
+ } catch (err) {
297
+ if (err instanceof ConsensusBlockedError) {
298
+ throw err;
299
+ }
300
+
301
+ const error = err instanceof Error ? err : new Error(String(err));
302
+ merged.onError?.(error, { toolName, args });
303
+
304
+ if (merged.failPolicy === "closed") {
305
+ throw new ConsensusBlockedError("Consensus deliberation failed", error);
306
+ }
307
+
308
+ return fn(toolName, args);
309
+ }
310
+ };
311
+ },
312
+
313
+ /**
314
+ * LangChain adapter — dynamically loads @consensus-tools/langchain.
315
+ */
316
+ async langchain(_chain: unknown, config?: Partial<UniversalConfig>): Promise<unknown> {
317
+ let mod: Record<string, unknown>;
318
+ try {
319
+ mod = await import("@consensus-tools/langchain") as Record<string, unknown>;
320
+ } catch {
321
+ throw new MissingDependencyError("@consensus-tools/langchain");
322
+ }
323
+
324
+ const HandlerClass = mod["ConsensusGuardCallbackHandler"] as
325
+ | (new (config: Record<string, unknown>) => unknown)
326
+ | undefined;
327
+
328
+ if (!HandlerClass) {
329
+ throw new Error("@consensus-tools/langchain does not export ConsensusGuardCallbackHandler");
330
+ }
331
+
332
+ const handler = new HandlerClass({
333
+ policy: config?.policy ?? "majority",
334
+ guards: config?.guards,
335
+ onDecision: config?.onDecision ? (d: unknown) => config.onDecision?.(d as any) : undefined,
336
+ });
337
+
338
+ return handler;
339
+ },
340
+
341
+ /**
342
+ * AI SDK (Vercel) adapter — dynamically loads @consensus-tools/ai-sdk.
343
+ */
344
+ async aiSdk(fn: unknown, config?: Partial<UniversalConfig>): Promise<unknown> {
345
+ let mod: Record<string, unknown>;
346
+ try {
347
+ mod = await import("@consensus-tools/ai-sdk") as Record<string, unknown>;
348
+ } catch {
349
+ throw new MissingDependencyError("@consensus-tools/ai-sdk");
350
+ }
351
+ if (typeof mod["createGuardedGenerate"] === "function") {
352
+ return (mod["createGuardedGenerate"] as (fn: unknown, config?: unknown) => unknown)(fn, config);
353
+ }
354
+ throw new Error("@consensus-tools/ai-sdk does not export createGuardedGenerate");
355
+ },
356
+
357
+ /**
358
+ * MCP adapter — dynamically loads @consensus-tools/mcp.
359
+ */
360
+ async mcp(config?: Partial<UniversalConfig>): Promise<unknown> {
361
+ let mod: Record<string, unknown>;
362
+ try {
363
+ mod = await import("@consensus-tools/mcp") as Record<string, unknown>;
364
+ } catch {
365
+ throw new MissingDependencyError("@consensus-tools/mcp");
366
+ }
367
+ if (typeof mod["createMcpServer"] === "function") {
368
+ return (mod["createMcpServer"] as (config?: unknown) => unknown)(config);
369
+ }
370
+ throw new Error("@consensus-tools/mcp does not export createMcpServer");
371
+ },
372
+ };
373
+
374
+ // ── Re-exports ───────────────────────────────────────────────────────
375
+ export { resolveWrappable } from "./resolve.js";
376
+ export { policyToStrategy, resolvePolicyType, DEFAULTS, DEFAULT_GUARD, DEFAULT_POLICY, DEFAULT_PERSONA_TRIO, DEFAULT_PERSONA_COUNT, DEFAULT_PACK } from "./defaults.js";
377
+ export { createLogger } from "./logger.js";
378
+ export { ConsensusBlockedError, MissingDependencyError, ConfigError } from "./errors.js";
379
+ export { ReputationManager } from "./reputation-manager.js";
380
+ export { classifyTool } from "./risk-tiers.js";
381
+ export { deliberate } from "./persona-reviewer-factory.js";
382
+ export type {
383
+ Wrappable, ToolExecutor, UniversalConfig, FailPolicy, ExecutionMode,
384
+ LogEvent, ModelAdapter, ModelMessage, LlmDecisionResult, FeedbackSignal,
385
+ RiskTier, RiskTierMap,
386
+ } from "./types.js";
package/src/logger.ts ADDED
@@ -0,0 +1,65 @@
1
+ import type { LifecycleHooks, DecisionResult } from "@consensus-tools/wrapper";
2
+ import type { LogEvent, UniversalConfig } from "./types.js";
3
+
4
+ type LogFn = (event: LogEvent) => void;
5
+
6
+ /** No-op hooks — used when logging is disabled. */
7
+ const NO_OP_HOOKS: LifecycleHooks = {};
8
+
9
+ function emit(logFn: LogFn, event: string, data: Record<string, unknown>): void {
10
+ logFn({ event, data, timestamp: Date.now() });
11
+ }
12
+
13
+ /**
14
+ * Creates wrapper lifecycle hooks that emit structured log events.
15
+ *
16
+ * Events:
17
+ * deliberation.start — before the wrapped function runs
18
+ * deliberation.result — after a decision is reached (allow/block/escalate)
19
+ * deliberation.error — when deliberation throws
20
+ */
21
+ export function createLogger(config: Pick<UniversalConfig, "logger">): LifecycleHooks {
22
+ const { logger } = config;
23
+
24
+ if (logger === false) {
25
+ return NO_OP_HOOKS;
26
+ }
27
+
28
+ const logFn: LogFn =
29
+ typeof logger === "function"
30
+ ? logger
31
+ : (event: LogEvent) => {
32
+ // eslint-disable-next-line no-console
33
+ console.debug(`[consensus] ${event.event}`, event.data);
34
+ };
35
+
36
+ return {
37
+ beforeSubmit(args: unknown[]) {
38
+ emit(logFn, "deliberation.start", { args });
39
+ },
40
+ afterResolve(result: DecisionResult) {
41
+ emit(logFn, "deliberation.result", {
42
+ action: result.action,
43
+ aggregateScore: result.aggregateScore,
44
+ attempt: result.attempt,
45
+ scoresCount: result.scores.length,
46
+ });
47
+ },
48
+ onBlock(result: DecisionResult) {
49
+ emit(logFn, "deliberation.result", {
50
+ action: "block",
51
+ aggregateScore: result.aggregateScore,
52
+ attempt: result.attempt,
53
+ scoresCount: result.scores.length,
54
+ });
55
+ },
56
+ onEscalate(result: DecisionResult) {
57
+ emit(logFn, "deliberation.result", {
58
+ action: "escalate",
59
+ aggregateScore: result.aggregateScore,
60
+ attempt: result.attempt,
61
+ scoresCount: result.scores.length,
62
+ });
63
+ },
64
+ };
65
+ }