@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.
- package/LICENSE +201 -0
- package/README.md +451 -0
- package/dist/__tests__/defaults.test.d.ts +2 -0
- package/dist/__tests__/defaults.test.d.ts.map +1 -0
- package/dist/__tests__/defaults.test.js +55 -0
- package/dist/__tests__/defaults.test.js.map +1 -0
- package/dist/__tests__/fail-policy.test.d.ts +2 -0
- package/dist/__tests__/fail-policy.test.d.ts.map +1 -0
- package/dist/__tests__/fail-policy.test.js +80 -0
- package/dist/__tests__/fail-policy.test.js.map +1 -0
- package/dist/__tests__/frameworks.test.d.ts +2 -0
- package/dist/__tests__/frameworks.test.d.ts.map +1 -0
- package/dist/__tests__/frameworks.test.js +86 -0
- package/dist/__tests__/frameworks.test.js.map +1 -0
- package/dist/__tests__/logger.test.d.ts +2 -0
- package/dist/__tests__/logger.test.d.ts.map +1 -0
- package/dist/__tests__/logger.test.js +77 -0
- package/dist/__tests__/logger.test.js.map +1 -0
- package/dist/__tests__/resolve.test.d.ts +2 -0
- package/dist/__tests__/resolve.test.d.ts.map +1 -0
- package/dist/__tests__/resolve.test.js +71 -0
- package/dist/__tests__/resolve.test.js.map +1 -0
- package/dist/__tests__/wrap.test.d.ts +2 -0
- package/dist/__tests__/wrap.test.d.ts.map +1 -0
- package/dist/__tests__/wrap.test.js +90 -0
- package/dist/__tests__/wrap.test.js.map +1 -0
- package/dist/defaults.d.ts +20 -0
- package/dist/defaults.d.ts.map +1 -0
- package/dist/defaults.js +48 -0
- package/dist/defaults.js.map +1 -0
- package/dist/errors.d.ts +23 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +31 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +239 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +12 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +55 -0
- package/dist/logger.js.map +1 -0
- package/dist/resolve.d.ts +9 -0
- package/dist/resolve.d.ts.map +1 -0
- package/dist/resolve.js +25 -0
- package/dist/resolve.js.map +1 -0
- package/dist/types.d.ts +35 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +82 -0
- package/src/__tests__/defaults.test.ts +71 -0
- package/src/__tests__/fail-policy.test.ts +107 -0
- package/src/__tests__/frameworks.test.ts +106 -0
- package/src/__tests__/logger.test.ts +93 -0
- package/src/__tests__/resolve.test.ts +80 -0
- package/src/__tests__/wrap.test.ts +110 -0
- package/src/consensus-llm.test.ts +260 -0
- package/src/defaults.ts +124 -0
- package/src/errors.ts +35 -0
- package/src/index.ts +386 -0
- package/src/logger.ts +65 -0
- package/src/persona-reviewer-factory.ts +387 -0
- package/src/reputation-manager.test.ts +131 -0
- package/src/reputation-manager.ts +168 -0
- package/src/resolve.ts +30 -0
- package/src/risk-tiers.test.ts +36 -0
- package/src/risk-tiers.ts +49 -0
- 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
|
+
}
|