@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
@@ -0,0 +1,387 @@
1
+ import crypto from "node:crypto";
2
+ import { resolveConsensus } from "@consensus-tools/core";
3
+ import { createGuardTemplate, GUARD_CONFIGS } from "@consensus-tools/guards";
4
+ import type { PersonaConfig, EvalPersonaConfig } from "@consensus-tools/personas";
5
+ import type { ConsensusInput, ConsensusResult } from "@consensus-tools/schemas";
6
+ import type { ModelAdapter, ModelMessage, LlmDecisionResult } from "./types.js";
7
+ import type { ReputationManager } from "./reputation-manager.js";
8
+ import { classifyTool } from "./risk-tiers.js";
9
+ import type { RiskTierMap } from "./types.js";
10
+
11
+ // ── Persona Reviewer Factory ─────────────────────────────────────────
12
+ // Creates LLM-backed persona reviewers that use resolveConsensus()
13
+ // for multi-model deliberation with reputation-weighted voting.
14
+ //
15
+ // Architecture:
16
+ // 1. Regex pre-screen (sub-ms, deterministic)
17
+ // 2. Risk tier check (low = fast-path regex only)
18
+ // 3. Parallel LLM calls per persona (with timeout + fallback)
19
+ // 4. Parse votes from LLM responses
20
+ // 5. Synthesize ConsensusInput (Job, Submissions, Votes)
21
+ // 6. Call resolveConsensus() with the configured policy
22
+ // 7. Return LlmDecisionResult
23
+
24
+ // ── Vote Parsing ─────────────────────────────────────────────────────
25
+
26
+ interface ParsedVote {
27
+ vote: "YES" | "NO" | "REWRITE";
28
+ confidence: number;
29
+ rationale: string;
30
+ }
31
+
32
+ const VOTE_PATTERN = /\b(YES|NO|REWRITE)\b/i;
33
+ const CONFIDENCE_PATTERN = /confidence[:\s]*([0-9]*\.?[0-9]+)/i;
34
+
35
+ function parseVoteFromLlm(response: string): ParsedVote | null {
36
+ const voteMatch = response.match(VOTE_PATTERN);
37
+ if (!voteMatch) return null;
38
+
39
+ const vote = voteMatch[1]!.toUpperCase() as "YES" | "NO" | "REWRITE";
40
+ const confMatch = response.match(CONFIDENCE_PATTERN);
41
+ const confidence = confMatch?.[1] ? Math.min(1, Math.max(0, parseFloat(confMatch[1]))) : 0.5;
42
+
43
+ // Use the full response as rationale (stripped of vote/confidence lines)
44
+ const rationale = response
45
+ .replace(/^.*\b(YES|NO|REWRITE)\b.*$/im, "")
46
+ .replace(/^.*confidence.*$/im, "")
47
+ .trim()
48
+ .slice(0, 500) || "No rationale provided";
49
+
50
+ return { vote, confidence, rationale };
51
+ }
52
+
53
+ // ── Prompt Construction ──────────────────────────────────────────────
54
+
55
+ function buildPersonaPrompt(
56
+ persona: PersonaConfig,
57
+ toolName: string,
58
+ args: Record<string, unknown>,
59
+ regexSignals: string[],
60
+ ): ModelMessage[] {
61
+ // Use systemPrompt if available (EvalPersonaConfig), otherwise construct from role/bias
62
+ const evalPersona = persona as Partial<EvalPersonaConfig>;
63
+ const systemPrompt = evalPersona.systemPrompt ??
64
+ `You are a ${persona.role} reviewer named ${persona.name}.` +
65
+ (persona.bias ? ` Your bias: ${persona.bias}.` : "") +
66
+ (persona.non_negotiables?.length
67
+ ? ` Non-negotiables: ${persona.non_negotiables.join(", ")}.`
68
+ : "") +
69
+ (persona.failure_modes?.length
70
+ ? ` Watch for: ${persona.failure_modes.join(", ")}.`
71
+ : "");
72
+
73
+ const regexContext = regexSignals.length > 0
74
+ ? `\n\nRegex pre-screen detected these signals:\n${regexSignals.map((s) => `- ${s}`).join("\n")}`
75
+ : "\n\nRegex pre-screen: no pattern matches detected.";
76
+
77
+ const userPrompt =
78
+ `Evaluate this tool call for risks:\n\n` +
79
+ `Tool: ${toolName}\n` +
80
+ `Arguments: ${JSON.stringify(args, null, 2)}\n` +
81
+ regexContext +
82
+ `\n\nRespond with:\n` +
83
+ `VOTE: YES (safe to proceed), NO (block this action), or REWRITE (needs modification)\n` +
84
+ `CONFIDENCE: 0.0 to 1.0\n` +
85
+ `RATIONALE: Brief explanation of your decision`;
86
+
87
+ return [
88
+ { role: "system" as const, content: systemPrompt },
89
+ { role: "user" as const, content: userPrompt },
90
+ ];
91
+ }
92
+
93
+ // ── Regex Pre-Screen ─────────────────────────────────────────────────
94
+
95
+ function runRegexPreScreen(
96
+ toolName: string,
97
+ args: Record<string, unknown>,
98
+ guards: string[],
99
+ ): string[] {
100
+ const signals: string[] = [];
101
+
102
+ for (const domain of guards) {
103
+ const config = GUARD_CONFIGS[domain];
104
+ if (!config) continue;
105
+
106
+ try {
107
+ const template = createGuardTemplate(domain, config);
108
+ const votes = template.evaluate({
109
+ boardId: "facade",
110
+ action: { type: toolName, payload: args },
111
+ });
112
+
113
+ for (const vote of votes) {
114
+ if (vote.vote === "NO" || (vote.risk && vote.risk > 0.5)) {
115
+ signals.push(`[${domain}] ${vote.reason} (risk: ${vote.risk ?? "unknown"})`);
116
+ }
117
+ }
118
+ } catch {
119
+ // Regex pre-screen failure is non-fatal
120
+ }
121
+ }
122
+
123
+ return signals;
124
+ }
125
+
126
+ // ── LLM Call with Timeout ────────────────────────────────────────────
127
+
128
+ async function callLlmWithTimeout(
129
+ model: ModelAdapter,
130
+ messages: ModelMessage[],
131
+ timeoutMs: number,
132
+ ): Promise<string> {
133
+ const controller = new AbortController();
134
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
135
+
136
+ try {
137
+ const result = await Promise.race([
138
+ model(messages),
139
+ new Promise<never>((_, reject) => {
140
+ controller.signal.addEventListener("abort", () =>
141
+ reject(new Error("LLM call timed out")),
142
+ );
143
+ }),
144
+ ]);
145
+ return result;
146
+ } finally {
147
+ clearTimeout(timer);
148
+ }
149
+ }
150
+
151
+ // ── Regex Fallback Vote ──────────────────────────────────────────────
152
+
153
+ function regexFallbackVote(
154
+ persona: PersonaConfig,
155
+ toolName: string,
156
+ args: Record<string, unknown>,
157
+ guards: string[],
158
+ ): ParsedVote {
159
+ const signals = runRegexPreScreen(toolName, args, guards);
160
+ if (signals.length > 0) {
161
+ return {
162
+ vote: "NO",
163
+ confidence: 0.6,
164
+ rationale: `Regex fallback: ${signals.join("; ")}`,
165
+ };
166
+ }
167
+ return {
168
+ vote: "YES",
169
+ confidence: 0.4,
170
+ rationale: "Regex fallback: no pattern matches (LLM unavailable)",
171
+ };
172
+ }
173
+
174
+ // ── Factory ──────────────────────────────────────────────────────────
175
+
176
+ export interface PersonaReviewerConfig {
177
+ model: ModelAdapter;
178
+ pack?: string;
179
+ personas?: PersonaConfig[];
180
+ guards?: string[];
181
+ policyType: string;
182
+ riskTiers?: RiskTierMap;
183
+ reputationManager: ReputationManager;
184
+ timeoutMs: number;
185
+ }
186
+
187
+ /**
188
+ * Run LLM persona deliberation on a tool call.
189
+ *
190
+ * Returns an LlmDecisionResult with per-persona votes, consensus trace,
191
+ * and final action (allow/block/escalate).
192
+ */
193
+ export async function deliberate(
194
+ config: PersonaReviewerConfig,
195
+ toolName: string,
196
+ args: Record<string, unknown>,
197
+ ): Promise<LlmDecisionResult> {
198
+ const decisionId = `dec_${crypto.randomUUID().slice(0, 12)}`;
199
+ const personas = config.reputationManager.getPersonas();
200
+ const guards = config.guards ?? ["security", "compliance", "user-impact"];
201
+
202
+ // 1. Regex pre-screen
203
+ const regexSignals = runRegexPreScreen(toolName, args, guards);
204
+
205
+ // 2. Risk tier check
206
+ const tier = classifyTool(toolName, config.riskTiers);
207
+ if (tier === "low") {
208
+ // Fast-path: regex only, no LLM calls
209
+ const hasRisk = regexSignals.length > 0;
210
+ return {
211
+ decisionId,
212
+ action: hasRisk ? "block" : "allow",
213
+ votes: personas.map((p) => ({
214
+ personaId: p.id,
215
+ personaName: p.name,
216
+ vote: hasRisk ? ("NO" as const) : ("YES" as const),
217
+ confidence: hasRisk ? 0.7 : 0.3,
218
+ rationale: hasRisk
219
+ ? `Fast-path regex: ${regexSignals.join("; ")}`
220
+ : "Fast-path: low-risk tool, no regex signals",
221
+ source: "regex_fallback" as const,
222
+ })),
223
+ policy: "fast_path",
224
+ consensusTrace: { tier: "low", regexSignals },
225
+ aggregateScore: hasRisk ? 0.0 : 1.0,
226
+ };
227
+ }
228
+
229
+ // 3. Parallel LLM calls per persona (with timeout + fallback)
230
+ const voteResults = await Promise.all(
231
+ personas.map(async (persona) => {
232
+ const messages = buildPersonaPrompt(persona, toolName, args, regexSignals);
233
+
234
+ try {
235
+ const response = await callLlmWithTimeout(config.model, messages, config.timeoutMs);
236
+ const parsed = parseVoteFromLlm(response);
237
+
238
+ if (parsed) {
239
+ return {
240
+ personaId: persona.id,
241
+ personaName: persona.name,
242
+ ...parsed,
243
+ source: "llm" as const,
244
+ };
245
+ }
246
+
247
+ // Unparseable response, fall back to regex
248
+ const fallback = regexFallbackVote(persona, toolName, args, guards);
249
+ return {
250
+ personaId: persona.id,
251
+ personaName: persona.name,
252
+ ...fallback,
253
+ source: "regex_fallback" as const,
254
+ };
255
+ } catch {
256
+ // LLM failure, fall back to regex
257
+ const fallback = regexFallbackVote(persona, toolName, args, guards);
258
+ return {
259
+ personaId: persona.id,
260
+ personaName: persona.name,
261
+ ...fallback,
262
+ source: "regex_fallback" as const,
263
+ };
264
+ }
265
+ }),
266
+ );
267
+
268
+ // 4. Synthesize ConsensusInput for resolveConsensus()
269
+ // Each persona creates a "submission" (their evaluation) and votes for it
270
+ const now = new Date().toISOString();
271
+ const jobId = `job_facade_${decisionId}`;
272
+
273
+ // Create a minimal Job with the configured policy
274
+ const job = {
275
+ id: jobId,
276
+ boardId: "",
277
+ status: "SUBMITTED" as const,
278
+ title: `Deliberation: ${toolName}`,
279
+ description: JSON.stringify(args),
280
+ createdByAgentId: "facade",
281
+ createdAt: now,
282
+ updatedAt: now,
283
+ mode: "VOTING" as const,
284
+ consensusPolicy: { type: config.policyType as any },
285
+ stakeRequired: 0,
286
+ reward: 0,
287
+ maxParticipants: personas.length,
288
+ minParticipants: 1,
289
+ };
290
+
291
+ // Each persona submits their evaluation
292
+ const submissions = voteResults.map((v, i) => ({
293
+ id: `sub_${decisionId}_${i}`,
294
+ jobId,
295
+ agentId: v.personaId,
296
+ submittedAt: now,
297
+ summary: v.rationale,
298
+ artifacts: { vote: v.vote, confidence: v.confidence, source: v.source },
299
+ confidence: v.confidence,
300
+ requestedPayout: 0,
301
+ status: "SUBMITTED" as const,
302
+ }));
303
+
304
+ // Each persona votes YES (+1) on their own submission
305
+ // and scores based on their confidence
306
+ const votes = voteResults.map((v, i) => ({
307
+ id: `vote_${decisionId}_${i}`,
308
+ jobId,
309
+ agentId: v.personaId,
310
+ submissionId: `sub_${decisionId}_${i}`,
311
+ score: v.vote === "YES" ? 1 : v.vote === "NO" ? -1 : 0,
312
+ weight: v.confidence,
313
+ rationale: v.rationale,
314
+ createdAt: now,
315
+ }));
316
+
317
+ // Reputation function from the manager
318
+ const reputation = (agentId: string) =>
319
+ config.reputationManager.getReputation(agentId);
320
+
321
+ // 5. Resolve consensus
322
+ const consensusInput: ConsensusInput = {
323
+ job: job as any,
324
+ submissions: submissions as any[],
325
+ votes: votes as any[],
326
+ reputation,
327
+ };
328
+
329
+ let consensusResult: ConsensusResult;
330
+ try {
331
+ consensusResult = resolveConsensus(consensusInput);
332
+ } catch {
333
+ // If resolution fails, fall back to simple majority
334
+ const yesCount = voteResults.filter((v) => v.vote === "YES").length;
335
+ const majority = yesCount > voteResults.length / 2;
336
+ consensusResult = {
337
+ winners: majority ? ["allow"] : ["block"],
338
+ winningSubmissionIds: [],
339
+ consensusTrace: { policy: "fallback_majority", reason: "resolve_error" },
340
+ finalArtifact: null,
341
+ };
342
+ }
343
+
344
+ // 6. Determine final action
345
+ const winnerIds = new Set(consensusResult.winners);
346
+ const winningVotes = voteResults.filter((v) => winnerIds.has(v.personaId));
347
+ const dominantVote = winningVotes.length > 0
348
+ ? winningVotes[0]!.vote
349
+ : voteResults[0]?.vote ?? "YES";
350
+
351
+ let action: "allow" | "block" | "escalate";
352
+ if (dominantVote === "YES") {
353
+ action = "allow";
354
+ } else if (dominantVote === "NO") {
355
+ action = "block";
356
+ } else {
357
+ action = "escalate";
358
+ }
359
+
360
+ // If no clear winner (empty winners), use simple vote counting
361
+ if (consensusResult.winners.length === 0) {
362
+ const yesCount = voteResults.filter((v) => v.vote === "YES").length;
363
+ const noCount = voteResults.filter((v) => v.vote === "NO").length;
364
+ action = yesCount >= noCount ? "allow" : "block";
365
+ }
366
+
367
+ // Compute aggregate score (0-1 based on vote distribution)
368
+ const totalConfidence = voteResults.reduce((s, v) => s + v.confidence, 0);
369
+ const yesConfidence = voteResults
370
+ .filter((v) => v.vote === "YES")
371
+ .reduce((s, v) => s + v.confidence, 0);
372
+ const aggregateScore = totalConfidence > 0 ? yesConfidence / totalConfidence : 0.5;
373
+
374
+ const result: LlmDecisionResult = {
375
+ decisionId,
376
+ action,
377
+ votes: voteResults,
378
+ policy: config.policyType,
379
+ consensusTrace: consensusResult.consensusTrace,
380
+ aggregateScore,
381
+ };
382
+
383
+ // 7. Record decision for reputation tracking
384
+ config.reputationManager.recordDecision(result);
385
+
386
+ return result;
387
+ }
@@ -0,0 +1,131 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { ReputationManager } from "./reputation-manager.js";
3
+ import type { PersonaConfig } from "@consensus-tools/personas";
4
+ import type { LlmDecisionResult } from "./types.js";
5
+
6
+ function makePersonas(): PersonaConfig[] {
7
+ return [
8
+ { id: "p1", name: "Security", role: "security", reputation: 0.55 },
9
+ { id: "p2", name: "Compliance", role: "compliance", reputation: 0.55 },
10
+ { id: "p3", name: "Operations", role: "operations", reputation: 0.55 },
11
+ ];
12
+ }
13
+
14
+ function makeDecision(overrides?: Partial<LlmDecisionResult>): LlmDecisionResult {
15
+ return {
16
+ decisionId: "dec_test",
17
+ action: "block",
18
+ votes: [
19
+ { personaId: "p1", personaName: "Security", vote: "NO", confidence: 0.9, rationale: "risky", source: "llm" },
20
+ { personaId: "p2", personaName: "Compliance", vote: "YES", confidence: 0.7, rationale: "ok", source: "llm" },
21
+ { personaId: "p3", personaName: "Operations", vote: "NO", confidence: 0.8, rationale: "risky", source: "llm" },
22
+ ],
23
+ policy: "MAJORITY_VOTE",
24
+ consensusTrace: {},
25
+ aggregateScore: 0.3,
26
+ ...overrides,
27
+ };
28
+ }
29
+
30
+ describe("ReputationManager", () => {
31
+ it("initializes all personas at 0.55", () => {
32
+ const mgr = new ReputationManager(makePersonas());
33
+ expect(mgr.getReputation("p1")).toBe(0.55);
34
+ expect(mgr.getReputation("p2")).toBe(0.55);
35
+ expect(mgr.getReputation("p3")).toBe(0.55);
36
+ });
37
+
38
+ it("returns 0.55 for unknown persona IDs", () => {
39
+ const mgr = new ReputationManager(makePersonas());
40
+ expect(mgr.getReputation("unknown")).toBe(0.55);
41
+ });
42
+
43
+ it("records decisions for feedback correlation", () => {
44
+ const mgr = new ReputationManager(makePersonas());
45
+ const decision = makeDecision();
46
+ mgr.recordDecision(decision);
47
+
48
+ // processFeedback should find this decision
49
+ const changes = mgr.processFeedback({
50
+ decisionId: "dec_test",
51
+ type: "override_block",
52
+ });
53
+ expect(changes.length).toBe(3);
54
+ });
55
+
56
+ it("updates reputation on override_block feedback", () => {
57
+ const mgr = new ReputationManager(makePersonas());
58
+ const decision = makeDecision();
59
+ mgr.recordDecision(decision);
60
+
61
+ // override_block means the block was wrong, ground truth is ALLOW
62
+ // p1 voted NO (misaligned with ALLOW), p2 voted YES (aligned), p3 voted NO (misaligned)
63
+ const changes = mgr.processFeedback({
64
+ decisionId: "dec_test",
65
+ type: "override_block",
66
+ });
67
+
68
+ // p2 (voted YES, aligned with ALLOW) should gain reputation
69
+ const p2Change = changes.find((c) => c.persona_id === "p2");
70
+ expect(p2Change!.delta).toBeGreaterThan(0);
71
+
72
+ // p1 (voted NO, misaligned with ALLOW) should lose reputation
73
+ const p1Change = changes.find((c) => c.persona_id === "p1");
74
+ expect(p1Change!.delta).toBeLessThan(0);
75
+ });
76
+
77
+ it("updates reputation on flag_miss feedback", () => {
78
+ const mgr = new ReputationManager(makePersonas());
79
+ const decision = makeDecision({ action: "allow" });
80
+ mgr.recordDecision(decision);
81
+
82
+ // flag_miss means the allow was wrong, ground truth is BLOCK
83
+ const changes = mgr.processFeedback({
84
+ decisionId: "dec_test",
85
+ type: "flag_miss",
86
+ });
87
+
88
+ // p1 and p3 (voted NO, aligned with BLOCK) should gain reputation
89
+ const p1Change = changes.find((c) => c.persona_id === "p1");
90
+ expect(p1Change!.delta).toBeGreaterThan(0);
91
+ });
92
+
93
+ it("returns empty changes for unknown decision IDs", () => {
94
+ const mgr = new ReputationManager(makePersonas());
95
+ const changes = mgr.processFeedback({
96
+ decisionId: "nonexistent",
97
+ type: "override_block",
98
+ });
99
+ expect(changes).toEqual([]);
100
+ });
101
+
102
+ it("triggers respawn when reputation drops below threshold", () => {
103
+ const personas = makePersonas();
104
+ personas[0]!.reputation = 0.10; // Already below default threshold of 0.15
105
+ const mgr = new ReputationManager(personas, 0.15);
106
+
107
+ const respawnHandler = vi.fn();
108
+ mgr.setRespawnHandler(respawnHandler);
109
+
110
+ // Record + feedback to trigger checkRespawn
111
+ const decision = makeDecision();
112
+ mgr.recordDecision(decision);
113
+ mgr.processFeedback({ decisionId: "dec_test", type: "override_block" });
114
+
115
+ // p1 started at 0.10, misaligned feedback pushes it further down
116
+ // Respawn should have been triggered
117
+ if (mgr.getReputation("p1") < 0.15 || respawnHandler.mock.calls.length === 0) {
118
+ // Persona was replaced, check new personas list
119
+ const currentPersonas = mgr.getPersonas();
120
+ // Either the original was respawned or it's below threshold
121
+ expect(currentPersonas.length).toBe(3);
122
+ }
123
+ });
124
+
125
+ it("getPersonas returns copies", () => {
126
+ const mgr = new ReputationManager(makePersonas());
127
+ const p1 = mgr.getPersonas();
128
+ const p2 = mgr.getPersonas();
129
+ expect(p1).not.toBe(p2);
130
+ });
131
+ });
@@ -0,0 +1,168 @@
1
+ import { updateReputation } from "@consensus-tools/personas";
2
+ import { buildLearningSummary, mutatePersona } from "@consensus-tools/personas";
3
+ import type { PersonaConfig, ReputationChange } from "@consensus-tools/personas";
4
+ import type { IStorage } from "@consensus-tools/storage";
5
+ import type { FeedbackSignal, LlmDecisionResult } from "./types.js";
6
+
7
+ // ── Reputation Manager ───────────────────────────────────────────────
8
+ // In-memory reputation tracking with optional persistence.
9
+ // Updates from human feedback signals (onFeedback), not self-consensus.
10
+ // Triggers persona respawn when reputation drops below threshold.
11
+
12
+ export interface RespawnEvent {
13
+ oldPersona: PersonaConfig;
14
+ newPersona: PersonaConfig;
15
+ reputation: number;
16
+ reason: string;
17
+ }
18
+
19
+ export class ReputationManager {
20
+ private scores: Map<string, number> = new Map();
21
+ private decisions: Map<string, LlmDecisionResult> = new Map();
22
+ private decisionHistory: LlmDecisionResult[] = [];
23
+ private onRespawn?: (event: RespawnEvent) => void;
24
+
25
+ constructor(
26
+ private personas: PersonaConfig[],
27
+ private threshold: number = 0.15,
28
+ private store?: IStorage,
29
+ ) {
30
+ for (const p of personas) {
31
+ this.scores.set(p.id, p.reputation ?? 0.55);
32
+ }
33
+ }
34
+
35
+ /** Set callback for respawn events. */
36
+ setRespawnHandler(handler: (event: RespawnEvent) => void): void {
37
+ this.onRespawn = handler;
38
+ }
39
+
40
+ /** Get current reputation for a persona. */
41
+ getReputation(personaId: string): number {
42
+ return this.scores.get(personaId) ?? 0.55;
43
+ }
44
+
45
+ /** Get all current reputation scores. */
46
+ getAllReputations(): Map<string, number> {
47
+ return new Map(this.scores);
48
+ }
49
+
50
+ /** Record a decision for later feedback correlation. */
51
+ recordDecision(result: LlmDecisionResult): void {
52
+ this.decisions.set(result.decisionId, result);
53
+ this.decisionHistory.push(result);
54
+ // Keep last 100 decisions for respawn analysis
55
+ if (this.decisionHistory.length > 100) {
56
+ this.decisionHistory.shift();
57
+ }
58
+ }
59
+
60
+ /** Process human feedback signal and update reputation. */
61
+ processFeedback(signal: FeedbackSignal): ReputationChange[] {
62
+ const decision = this.decisions.get(signal.decisionId);
63
+ if (!decision) return [];
64
+
65
+ // Map feedback to a "ground truth" final decision
66
+ // override_block = human says the block was wrong, action should have been ALLOW
67
+ // flag_miss = human says the allow was wrong, action should have been BLOCK
68
+ const groundTruth = signal.type === "override_block" ? "ALLOW" : "BLOCK";
69
+
70
+ const votes = decision.votes.map((v) => ({
71
+ persona_id: v.personaId,
72
+ vote: v.vote,
73
+ confidence: v.confidence,
74
+ }));
75
+
76
+ const personaConfigs = this.personas.map((p) => ({
77
+ ...p,
78
+ reputation: this.scores.get(p.id) ?? 0.55,
79
+ }));
80
+
81
+ const result = updateReputation(votes, groundTruth, personaConfigs);
82
+
83
+ // Apply changes
84
+ for (const change of result.changes) {
85
+ this.scores.set(change.persona_id, change.reputation_after);
86
+ }
87
+
88
+ // Check for respawn
89
+ this.checkRespawn();
90
+
91
+ // Persist if store configured
92
+ this.persist();
93
+
94
+ return result.changes;
95
+ }
96
+
97
+ /** Check if any persona needs respawn. */
98
+ private checkRespawn(): void {
99
+ for (let i = 0; i < this.personas.length; i++) {
100
+ const persona = this.personas[i]!;
101
+ const rep = this.scores.get(persona.id) ?? 0.55;
102
+
103
+ if (rep < this.threshold) {
104
+ // Build learning summary from decision history
105
+ const decisionRecords = this.decisionHistory.map((d) => ({
106
+ final_decision: d.action === "allow" ? "ALLOW" : "BLOCK",
107
+ votes: d.votes.map((v) => ({
108
+ persona_id: v.personaId,
109
+ vote: v.vote,
110
+ confidence: v.confidence,
111
+ })),
112
+ }));
113
+
114
+ const learning = buildLearningSummary(persona.id, decisionRecords);
115
+ const successor = mutatePersona(persona, learning);
116
+
117
+ // Replace persona
118
+ this.personas[i] = successor;
119
+ this.scores.delete(persona.id);
120
+ this.scores.set(successor.id, successor.reputation ?? 0.55);
121
+
122
+ this.onRespawn?.({
123
+ oldPersona: persona,
124
+ newPersona: successor,
125
+ reputation: rep,
126
+ reason: `Reputation ${rep.toFixed(3)} below threshold ${this.threshold}`,
127
+ });
128
+ }
129
+ }
130
+ }
131
+
132
+ /** Get current persona list (may include respawned successors). */
133
+ getPersonas(): PersonaConfig[] {
134
+ return [...this.personas];
135
+ }
136
+
137
+ /** Persist reputation scores to store (if configured). */
138
+ private persist(): void {
139
+ if (!this.store) return;
140
+ const data: Record<string, number> = {};
141
+ for (const [id, score] of this.scores) {
142
+ data[id] = score;
143
+ }
144
+ this.store.update((state) => {
145
+ (state as any).reputation = data;
146
+ }).catch(() => {
147
+ // Persistence failure is non-fatal
148
+ });
149
+ }
150
+
151
+ /** Load reputation scores from store (if configured). */
152
+ async load(): Promise<void> {
153
+ if (!this.store) return;
154
+ try {
155
+ const state = await this.store.getState();
156
+ const saved = (state as any)?.reputation as Record<string, number> | undefined;
157
+ if (saved) {
158
+ for (const [id, score] of Object.entries(saved)) {
159
+ if (this.scores.has(id)) {
160
+ this.scores.set(id, score);
161
+ }
162
+ }
163
+ }
164
+ } catch {
165
+ // Load failure is non-fatal, start with defaults
166
+ }
167
+ }
168
+ }