@consensus-tools/consensus-tools 0.1.0 → 0.1.2
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/README.md +17 -14
- package/bin/consensus-tools.js +34 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +11 -6
- package/src/cli.ts +214 -109
- package/src/cliConfig.ts +97 -0
- package/src/initWizard.ts +236 -0
- package/src/standalone.ts +409 -0
- package/src/testing/consensusTestRunner.ts +251 -0
- /package/{LICENSE → LICENSE.txt} +0 -0
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { promises as fs } from 'node:fs';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import type { ConsensusToolsBackendCli } from './cli';
|
|
6
|
+
import { initRepo } from './cli';
|
|
7
|
+
import {
|
|
8
|
+
getConfigValue,
|
|
9
|
+
loadCliConfig,
|
|
10
|
+
parseValue,
|
|
11
|
+
resolveRemoteBaseUrl,
|
|
12
|
+
saveCliConfig,
|
|
13
|
+
setConfigValue
|
|
14
|
+
} from './cliConfig';
|
|
15
|
+
import type { ConsensusCliConfig } from './cliConfig';
|
|
16
|
+
import { ConsensusToolsClient } from './network/client';
|
|
17
|
+
import { renderTable } from './util/table';
|
|
18
|
+
import { runConsensusPolicyTests } from './testing/consensusTestRunner';
|
|
19
|
+
|
|
20
|
+
type Parsed = { positionals: string[]; options: Record<string, any> };
|
|
21
|
+
|
|
22
|
+
function output(data: any, json: boolean) {
|
|
23
|
+
if (json) {
|
|
24
|
+
console.log(JSON.stringify(data, null, 2));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (typeof data === 'string') {
|
|
28
|
+
console.log(data);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
console.log(JSON.stringify(data, null, 2));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function readStdinIfAny(): Promise<string | undefined> {
|
|
35
|
+
if (process.stdin.isTTY) return undefined;
|
|
36
|
+
const chunks: Buffer[] = [];
|
|
37
|
+
for await (const chunk of process.stdin) {
|
|
38
|
+
chunks.push(Buffer.from(chunk));
|
|
39
|
+
}
|
|
40
|
+
const text = Buffer.concat(chunks).toString('utf8').trim();
|
|
41
|
+
return text || undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function resolveAgentId(cfg: ConsensusCliConfig): string {
|
|
45
|
+
const fromEnv = process.env.CONSENSUS_AGENT_ID;
|
|
46
|
+
if (fromEnv) return fromEnv;
|
|
47
|
+
if (cfg.agentId) return cfg.agentId;
|
|
48
|
+
|
|
49
|
+
let user = 'cli';
|
|
50
|
+
try {
|
|
51
|
+
const u = os.userInfo();
|
|
52
|
+
if (u?.username) user = u.username;
|
|
53
|
+
} catch {
|
|
54
|
+
// ignore
|
|
55
|
+
}
|
|
56
|
+
return `${user}@${os.hostname()}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function createRemoteClient(cfg: ConsensusCliConfig): ConsensusToolsClient {
|
|
60
|
+
const baseUrl = resolveRemoteBaseUrl(cfg.boards.remote.url, cfg.boards.remote.boardId);
|
|
61
|
+
const envName = cfg.boards.remote.auth.apiKeyEnv || 'CONSENSUS_API_KEY';
|
|
62
|
+
const token = process.env[envName] || '';
|
|
63
|
+
if (!token) {
|
|
64
|
+
throw new Error(`Missing access token. Set ${envName} (or run consensus-tools init).`);
|
|
65
|
+
}
|
|
66
|
+
return new ConsensusToolsClient(baseUrl, token);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function createBackend(cfg: ConsensusCliConfig): ConsensusToolsBackendCli {
|
|
70
|
+
const requireRemote = () => {
|
|
71
|
+
if (cfg.activeBoard !== 'remote') {
|
|
72
|
+
throw new Error('Local mode via consensus-tools CLI is not supported yet. Use `.consensus/api/*.sh` or switch to remote.');
|
|
73
|
+
}
|
|
74
|
+
return createRemoteClient(cfg);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
postJob: async (agentId, input) => requireRemote().postJob(agentId, input),
|
|
79
|
+
listJobs: async (filters) => requireRemote().listJobs(filters || {}),
|
|
80
|
+
getJob: async (jobId) => requireRemote().getJob(jobId),
|
|
81
|
+
getStatus: async (jobId) => requireRemote().getStatus(jobId),
|
|
82
|
+
submitJob: async (agentId, jobId, input) => requireRemote().submitJob(agentId, jobId, input),
|
|
83
|
+
listSubmissions: async (jobId) => {
|
|
84
|
+
const status = await requireRemote().getStatus(jobId);
|
|
85
|
+
return status?.submissions || [];
|
|
86
|
+
},
|
|
87
|
+
listVotes: async (jobId) => {
|
|
88
|
+
const status = await requireRemote().getStatus(jobId);
|
|
89
|
+
return status?.votes || [];
|
|
90
|
+
},
|
|
91
|
+
vote: async (agentId, jobId, input) => requireRemote().vote(agentId, jobId, input),
|
|
92
|
+
resolveJob: async (agentId, jobId, input) => requireRemote().resolveJob(agentId, jobId, input)
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function parseArgs(args: string[]): Parsed {
|
|
97
|
+
const options: Record<string, any> = {};
|
|
98
|
+
const positionals: string[] = [];
|
|
99
|
+
|
|
100
|
+
const takeValue = (i: number) => {
|
|
101
|
+
if (i + 1 >= args.length) throw new Error(`Missing value for ${args[i]}`);
|
|
102
|
+
return args[i + 1];
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
106
|
+
const a = args[i];
|
|
107
|
+
if (a === '--') {
|
|
108
|
+
positionals.push(...args.slice(i + 1));
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
if (a.startsWith('--')) {
|
|
112
|
+
const eq = a.indexOf('=');
|
|
113
|
+
const key = (eq >= 0 ? a.slice(2, eq) : a.slice(2)).trim();
|
|
114
|
+
const value = eq >= 0 ? a.slice(eq + 1) : undefined;
|
|
115
|
+
|
|
116
|
+
if (!key) continue;
|
|
117
|
+
|
|
118
|
+
// boolean flags
|
|
119
|
+
if (value === undefined && (i + 1 >= args.length || args[i + 1].startsWith('-'))) {
|
|
120
|
+
options[key] = true;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const raw = value ?? takeValue(i);
|
|
125
|
+
if (value === undefined) i += 1;
|
|
126
|
+
|
|
127
|
+
// collect repeatables
|
|
128
|
+
if (key === 'winner') {
|
|
129
|
+
(options.winner ||= []).push(raw);
|
|
130
|
+
} else {
|
|
131
|
+
options[key] = raw;
|
|
132
|
+
}
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (a === '-h' || a === '--help') {
|
|
136
|
+
options.help = true;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (a === '-v' || a === '--version') {
|
|
140
|
+
options.version = true;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
positionals.push(a);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { positionals, options };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function helpText() {
|
|
150
|
+
return [
|
|
151
|
+
'consensus-tools',
|
|
152
|
+
'',
|
|
153
|
+
'Usage:',
|
|
154
|
+
' consensus-tools <command> [subcommand] [options]',
|
|
155
|
+
'',
|
|
156
|
+
'Commands:',
|
|
157
|
+
' init [--force] [--wizard] [--templates-only]',
|
|
158
|
+
' config get <key>',
|
|
159
|
+
' config set <key> <value>',
|
|
160
|
+
' board use <local|remote> [url]',
|
|
161
|
+
' jobs post --title <title> [--desc <desc>] [--input <input>] [--mode SUBMISSION|VOTING] [--policy <key>] [--reward <n>] [--stake <n>] [--expires <seconds>] [--json]',
|
|
162
|
+
' jobs get <jobId> [--json]',
|
|
163
|
+
' jobs list [--tag <tag>] [--status <status>] [--mine] [--json]',
|
|
164
|
+
' submissions create <jobId> --artifact <json> [--summary <text>] [--confidence <n>] [--json]',
|
|
165
|
+
' submissions list <jobId> [--json]',
|
|
166
|
+
' votes cast <jobId> [--submission <id> | --choice <key>] [--weight <n>] [--json]',
|
|
167
|
+
' votes list <jobId> [--json]',
|
|
168
|
+
' resolve <jobId> [--winner <agentId> ...] [--submission <submissionId>] [--json]',
|
|
169
|
+
' result get <jobId> [--json]',
|
|
170
|
+
' tests run [--agents <n>] [--script <path>] [--openai-key <key>] [--model <name>]',
|
|
171
|
+
'',
|
|
172
|
+
'Notes:',
|
|
173
|
+
' - Hosted boards require an access token via env (default CONSENSUS_API_KEY).',
|
|
174
|
+
' - For OpenClaw integration: openclaw consensus <...>',
|
|
175
|
+
''
|
|
176
|
+
].join('\n');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function readPackageVersion(): Promise<string> {
|
|
180
|
+
try {
|
|
181
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
182
|
+
const pkgPath = path.join(here, '..', 'package.json');
|
|
183
|
+
const raw = await fs.readFile(pkgPath, 'utf8');
|
|
184
|
+
const pkg = JSON.parse(raw) as { version?: string };
|
|
185
|
+
return pkg.version || '0.0.0';
|
|
186
|
+
} catch {
|
|
187
|
+
return '0.0.0';
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function main() {
|
|
192
|
+
const argv = process.argv.slice(2);
|
|
193
|
+
const parsed = parseArgs(argv);
|
|
194
|
+
|
|
195
|
+
if (parsed.options.version) {
|
|
196
|
+
console.log(await readPackageVersion());
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const [cmd, sub, ...restPos] = parsed.positionals;
|
|
201
|
+
if (!cmd || parsed.options.help) {
|
|
202
|
+
console.log(helpText());
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (cmd === 'init') {
|
|
207
|
+
await initRepo({
|
|
208
|
+
rootDir: process.cwd(),
|
|
209
|
+
force: Boolean(parsed.options.force),
|
|
210
|
+
wizard: typeof parsed.options.wizard === 'boolean' ? Boolean(parsed.options.wizard) : undefined,
|
|
211
|
+
templatesOnly: Boolean(parsed.options['templates-only'] || parsed.options.templatesOnly)
|
|
212
|
+
});
|
|
213
|
+
console.log('Created .consensus templates.');
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (cmd === 'config') {
|
|
218
|
+
if (sub !== 'get' && sub !== 'set') throw new Error('Usage: consensus-tools config get <key> | set <key> <value>');
|
|
219
|
+
const [key, value] = restPos;
|
|
220
|
+
if (!key) throw new Error('Missing key');
|
|
221
|
+
if (sub === 'get') {
|
|
222
|
+
const cfg = await loadCliConfig();
|
|
223
|
+
output(getConfigValue(cfg, key) ?? null, true);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (value === undefined) throw new Error('Missing value');
|
|
227
|
+
const cfg = await loadCliConfig();
|
|
228
|
+
setConfigValue(cfg, key, parseValue(value));
|
|
229
|
+
await saveCliConfig(cfg);
|
|
230
|
+
output({ ok: true }, true);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (cmd === 'board') {
|
|
235
|
+
if (sub !== 'use') throw new Error('Usage: consensus-tools board use <local|remote> [url]');
|
|
236
|
+
const [type, url] = restPos;
|
|
237
|
+
if (type !== 'local' && type !== 'remote') throw new Error('board type must be local or remote');
|
|
238
|
+
const cfg = await loadCliConfig();
|
|
239
|
+
cfg.activeBoard = type;
|
|
240
|
+
if (type === 'remote' && url) cfg.boards.remote.url = url;
|
|
241
|
+
await saveCliConfig(cfg);
|
|
242
|
+
output({ activeBoard: cfg.activeBoard, url: cfg.boards.remote.url }, true);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Everything below needs backend + agentId.
|
|
247
|
+
const cfg = await loadCliConfig();
|
|
248
|
+
const agentId = resolveAgentId(cfg);
|
|
249
|
+
const backend = createBackend(cfg);
|
|
250
|
+
|
|
251
|
+
if (cmd === 'jobs') {
|
|
252
|
+
if (sub === 'post') {
|
|
253
|
+
if (!parsed.options.title) throw new Error('Missing required option: --title');
|
|
254
|
+
const input = (parsed.options.input as string | undefined) ?? (await readStdinIfAny());
|
|
255
|
+
const job = await backend.postJob(agentId, {
|
|
256
|
+
title: String(parsed.options.title),
|
|
257
|
+
desc: parsed.options.desc,
|
|
258
|
+
description: parsed.options.desc,
|
|
259
|
+
inputRef: input,
|
|
260
|
+
mode: parsed.options.mode,
|
|
261
|
+
policyKey: parsed.options.policy,
|
|
262
|
+
rewardAmount: parsed.options.reward ? Number(parsed.options.reward) : undefined,
|
|
263
|
+
stakeAmount: parsed.options.stake ? Number(parsed.options.stake) : undefined,
|
|
264
|
+
reward: parsed.options.reward ? Number(parsed.options.reward) : undefined,
|
|
265
|
+
stakeRequired: parsed.options.stake ? Number(parsed.options.stake) : undefined,
|
|
266
|
+
expiresSeconds: parsed.options.expires ? Number.parseInt(String(parsed.options.expires), 10) : undefined
|
|
267
|
+
});
|
|
268
|
+
output(job, Boolean(parsed.options.json));
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (sub === 'get') {
|
|
272
|
+
const [jobId] = restPos;
|
|
273
|
+
if (!jobId) throw new Error('Missing jobId');
|
|
274
|
+
const job = await backend.getJob(jobId);
|
|
275
|
+
if (!job) throw new Error('Job not found');
|
|
276
|
+
output(job, Boolean(parsed.options.json));
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (sub === 'list') {
|
|
280
|
+
const list = await backend.listJobs({
|
|
281
|
+
tag: parsed.options.tag,
|
|
282
|
+
status: parsed.options.status,
|
|
283
|
+
mine: parsed.options.mine ? agentId : undefined
|
|
284
|
+
});
|
|
285
|
+
if (parsed.options.json) {
|
|
286
|
+
output(list, true);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
const table = renderTable(
|
|
290
|
+
list.map((job) => ({
|
|
291
|
+
id: job.id,
|
|
292
|
+
title: job.title,
|
|
293
|
+
mode: job.mode ?? 'SUBMISSION',
|
|
294
|
+
status: job.status,
|
|
295
|
+
reward: (job as any).rewardAmount ?? (job as any).reward,
|
|
296
|
+
closes: (job as any).closesAt ?? (job as any).expiresAt
|
|
297
|
+
})),
|
|
298
|
+
[
|
|
299
|
+
{ key: 'id', label: 'ID' },
|
|
300
|
+
{ key: 'title', label: 'Title' },
|
|
301
|
+
{ key: 'mode', label: 'Mode' },
|
|
302
|
+
{ key: 'status', label: 'Status' },
|
|
303
|
+
{ key: 'reward', label: 'Reward', align: 'right' },
|
|
304
|
+
{ key: 'closes', label: 'Closes' }
|
|
305
|
+
]
|
|
306
|
+
);
|
|
307
|
+
console.log(table);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
throw new Error('Usage: consensus-tools jobs <post|get|list> ...');
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (cmd === 'submissions') {
|
|
314
|
+
if (sub === 'create') {
|
|
315
|
+
const [jobId] = restPos;
|
|
316
|
+
if (!jobId) throw new Error('Missing jobId');
|
|
317
|
+
if (!parsed.options.artifact) throw new Error('Missing required option: --artifact <json>');
|
|
318
|
+
const artifacts = JSON.parse(String(parsed.options.artifact));
|
|
319
|
+
const submission = await backend.submitJob(agentId, jobId, {
|
|
320
|
+
summary: String(parsed.options.summary ?? ''),
|
|
321
|
+
artifacts,
|
|
322
|
+
confidence: parsed.options.confidence ? Number(parsed.options.confidence) : 0.5
|
|
323
|
+
});
|
|
324
|
+
output(submission, Boolean(parsed.options.json));
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
if (sub === 'list') {
|
|
328
|
+
const [jobId] = restPos;
|
|
329
|
+
if (!jobId) throw new Error('Missing jobId');
|
|
330
|
+
const list = await backend.listSubmissions(jobId);
|
|
331
|
+
output(list, Boolean(parsed.options.json));
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
throw new Error('Usage: consensus-tools submissions <create|list> ...');
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (cmd === 'votes') {
|
|
338
|
+
if (sub === 'cast') {
|
|
339
|
+
const [jobId] = restPos;
|
|
340
|
+
if (!jobId) throw new Error('Missing jobId');
|
|
341
|
+
const submissionId = parsed.options.submission as string | undefined;
|
|
342
|
+
const choiceKey = parsed.options.choice as string | undefined;
|
|
343
|
+
const weight = parsed.options.weight ? Number(parsed.options.weight) : 1;
|
|
344
|
+
const vote = await backend.vote(agentId, jobId, {
|
|
345
|
+
submissionId,
|
|
346
|
+
choiceKey,
|
|
347
|
+
targetType: submissionId ? 'SUBMISSION' : choiceKey ? 'CHOICE' : undefined,
|
|
348
|
+
targetId: submissionId ?? choiceKey,
|
|
349
|
+
weight,
|
|
350
|
+
score: weight
|
|
351
|
+
});
|
|
352
|
+
output(vote, Boolean(parsed.options.json));
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
if (sub === 'list') {
|
|
356
|
+
const [jobId] = restPos;
|
|
357
|
+
if (!jobId) throw new Error('Missing jobId');
|
|
358
|
+
const list = await backend.listVotes(jobId);
|
|
359
|
+
output(list, Boolean(parsed.options.json));
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
throw new Error('Usage: consensus-tools votes <cast|list> ...');
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (cmd === 'resolve') {
|
|
366
|
+
const jobId = sub;
|
|
367
|
+
if (!jobId) throw new Error('Missing jobId');
|
|
368
|
+
const winners = (parsed.options.winner as string[] | undefined) || [];
|
|
369
|
+
const resolution = await backend.resolveJob(agentId, jobId, {
|
|
370
|
+
manualWinners: winners.length ? winners : undefined,
|
|
371
|
+
manualSubmissionId: parsed.options.submission
|
|
372
|
+
});
|
|
373
|
+
output(resolution, Boolean(parsed.options.json));
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (cmd === 'result') {
|
|
378
|
+
if (sub !== 'get') throw new Error('Usage: consensus-tools result get <jobId>');
|
|
379
|
+
const [jobId] = restPos;
|
|
380
|
+
if (!jobId) throw new Error('Missing jobId');
|
|
381
|
+
const status = backend.getStatus ? await backend.getStatus(jobId) : await backend.getJob(jobId);
|
|
382
|
+
output(status, Boolean(parsed.options.json));
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (cmd === 'tests') {
|
|
387
|
+
if (sub !== 'run') throw new Error('Usage: consensus-tools tests run [--agents N] [--script PATH] ...');
|
|
388
|
+
const agentCount = parsed.options.agents ? Number(parsed.options.agents) : 3;
|
|
389
|
+
const scriptPath = String(parsed.options.script || '.consensus/generation.ts');
|
|
390
|
+
const apiKey = (parsed.options['openai-key'] as string | undefined) || process.env.OPENAI_API_KEY;
|
|
391
|
+
const model = String(parsed.options.model || 'gpt-5.2');
|
|
392
|
+
const result = await runConsensusPolicyTests({
|
|
393
|
+
scriptPath,
|
|
394
|
+
agentCount,
|
|
395
|
+
apiKey: apiKey || undefined,
|
|
396
|
+
model
|
|
397
|
+
});
|
|
398
|
+
output(result, true);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
throw new Error(`Unknown command: ${cmd}`);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
main().catch((err) => {
|
|
406
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
407
|
+
console.error(message);
|
|
408
|
+
process.exitCode = 1;
|
|
409
|
+
});
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { promises as fs } from 'node:fs';
|
|
5
|
+
import OpenAI from 'openai';
|
|
6
|
+
import { JsonStorage } from '../storage/JsonStorage';
|
|
7
|
+
import { LedgerEngine } from '../ledger/ledger';
|
|
8
|
+
import { JobEngine } from '../jobs/engine';
|
|
9
|
+
import type { ConsensusPolicyType, Submission, Vote } from '../types';
|
|
10
|
+
import { defaultConfig } from '../config';
|
|
11
|
+
|
|
12
|
+
export type Persona = {
|
|
13
|
+
id: string;
|
|
14
|
+
name: string;
|
|
15
|
+
systemPrompt: string;
|
|
16
|
+
role?: 'accurate' | 'creative' | 'contrarian';
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type GenerationTask = {
|
|
20
|
+
title: string;
|
|
21
|
+
desc: string;
|
|
22
|
+
input: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type GenerationScript = {
|
|
26
|
+
name: string;
|
|
27
|
+
task: GenerationTask;
|
|
28
|
+
expectedAnswer: string;
|
|
29
|
+
personas: Persona[];
|
|
30
|
+
getPersonas?: (count: number) => Persona[];
|
|
31
|
+
buildPrompt?: (persona: Persona, task: GenerationTask, expectedAnswer: string) => { system: string; user: string };
|
|
32
|
+
mockResponse?: (persona: Persona, task: GenerationTask, expectedAnswer: string) => string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type RunnerOptions = {
|
|
36
|
+
scriptPath: string;
|
|
37
|
+
agentCount: number;
|
|
38
|
+
apiKey?: string;
|
|
39
|
+
model?: string;
|
|
40
|
+
verbose?: boolean;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type PolicyResult = {
|
|
44
|
+
policy: ConsensusPolicyType;
|
|
45
|
+
winnerAgentId?: string;
|
|
46
|
+
winningSubmissionId?: string;
|
|
47
|
+
finalAnswer?: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type RunnerResult = {
|
|
51
|
+
scriptName: string;
|
|
52
|
+
expectedAnswer: string;
|
|
53
|
+
results: PolicyResult[];
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const POLICY_TYPES: ConsensusPolicyType[] = [
|
|
57
|
+
'SINGLE_WINNER',
|
|
58
|
+
'HIGHEST_CONFIDENCE_SINGLE',
|
|
59
|
+
'OWNER_PICK',
|
|
60
|
+
'TOP_K_SPLIT',
|
|
61
|
+
'MAJORITY_VOTE',
|
|
62
|
+
'WEIGHTED_VOTE_SIMPLE',
|
|
63
|
+
'WEIGHTED_REPUTATION',
|
|
64
|
+
'TRUSTED_ARBITER'
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
export async function runConsensusPolicyTests(options: RunnerOptions): Promise<RunnerResult> {
|
|
68
|
+
const script = await loadScript(options.scriptPath);
|
|
69
|
+
const personas = script.getPersonas ? script.getPersonas(options.agentCount) : script.personas.slice(0, options.agentCount);
|
|
70
|
+
if (!personas.length) {
|
|
71
|
+
throw new Error('No personas available for test run.');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const log = (message: string, data?: Record<string, unknown>) => {
|
|
75
|
+
if (!options.verbose) return;
|
|
76
|
+
if (data) {
|
|
77
|
+
console.log(message, data);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
console.log(message);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const client = options.apiKey ? createOpenAIClient(options.apiKey) : null;
|
|
84
|
+
|
|
85
|
+
const results: PolicyResult[] = [];
|
|
86
|
+
for (const policy of POLICY_TYPES) {
|
|
87
|
+
log(`\n[policy] ${policy}`);
|
|
88
|
+
const { engine, ledger } = await createEngine();
|
|
89
|
+
const job = await engine.postJob('owner', {
|
|
90
|
+
title: script.task.title,
|
|
91
|
+
description: script.task.desc,
|
|
92
|
+
inputs: { input: script.task.input },
|
|
93
|
+
consensusPolicy: {
|
|
94
|
+
type: policy,
|
|
95
|
+
trustedArbiterAgentId: policy === 'TRUSTED_ARBITER' ? 'arbiter' : '',
|
|
96
|
+
minConfidence: policy === 'HIGHEST_CONFIDENCE_SINGLE' ? 0.5 : undefined,
|
|
97
|
+
topK: policy === 'TOP_K_SPLIT' ? 2 : undefined,
|
|
98
|
+
ordering: policy === 'TOP_K_SPLIT' ? 'confidence' : undefined
|
|
99
|
+
},
|
|
100
|
+
slashingPolicy: { enabled: false, slashPercent: 0, slashFlat: 0 }
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
log('[job] created', { jobId: job.id, consensusPolicy: job.consensusPolicy });
|
|
104
|
+
|
|
105
|
+
await ledger.faucet('owner', 100, 'test');
|
|
106
|
+
log('[ledger] faucet', { agentId: 'owner', amount: 100 });
|
|
107
|
+
|
|
108
|
+
const submissions: Submission[] = [];
|
|
109
|
+
for (const persona of personas) {
|
|
110
|
+
log('[persona] loaded', { id: persona.id, name: persona.name, role: persona.role });
|
|
111
|
+
const answer = await generateResponse(script, persona, client, options.model);
|
|
112
|
+
log('[persona] response', { id: persona.id, answer });
|
|
113
|
+
const submission = await engine.submitJob(persona.id, job.id, {
|
|
114
|
+
summary: `${persona.name} submission`,
|
|
115
|
+
artifacts: { answer },
|
|
116
|
+
confidence: persona.role === 'contrarian' ? 0.1 : 0.9
|
|
117
|
+
});
|
|
118
|
+
log('[submission] created', { submissionId: submission.id, agentId: submission.agentId, summary: submission.summary });
|
|
119
|
+
submissions.push(submission);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const correctSubmission = submissions.find((sub) => sub.artifacts?.answer === script.expectedAnswer) || submissions[0];
|
|
123
|
+
log('[submission] selected correct', { submissionId: correctSubmission.id, agentId: correctSubmission.agentId });
|
|
124
|
+
|
|
125
|
+
if (policy === 'MAJORITY_VOTE' || policy === 'WEIGHTED_VOTE_SIMPLE' || policy === 'WEIGHTED_REPUTATION') {
|
|
126
|
+
for (const persona of personas) {
|
|
127
|
+
await engine.vote(persona.id, job.id, {
|
|
128
|
+
submissionId: correctSubmission.id,
|
|
129
|
+
score: 1
|
|
130
|
+
} as Vote);
|
|
131
|
+
log('[vote] cast', {
|
|
132
|
+
voterId: persona.id,
|
|
133
|
+
submissionId: correctSubmission.id,
|
|
134
|
+
score: 1,
|
|
135
|
+
personaRole: persona.role
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const resolution = await engine.resolveJob(policy === 'TRUSTED_ARBITER' ? 'arbiter' : 'owner', job.id, {
|
|
141
|
+
manualWinners: policy === 'TRUSTED_ARBITER' || policy === 'OWNER_PICK' ? [correctSubmission.agentId] : undefined,
|
|
142
|
+
manualSubmissionId: policy === 'TRUSTED_ARBITER' || policy === 'OWNER_PICK' ? correctSubmission.id : undefined
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
log('[resolution] result', {
|
|
146
|
+
winners: resolution.winners,
|
|
147
|
+
winningSubmissionIds: resolution.winningSubmissionIds,
|
|
148
|
+
consensusTrace: resolution.consensusTrace
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
results.push({
|
|
152
|
+
policy,
|
|
153
|
+
winnerAgentId: resolution.winners[0],
|
|
154
|
+
winningSubmissionId: resolution.winningSubmissionIds?.[0],
|
|
155
|
+
finalAnswer: (resolution.finalArtifact as any)?.answer
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return { scriptName: script.name, expectedAnswer: script.expectedAnswer, results };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function createEngine() {
|
|
163
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'consensus-tools-policy-'));
|
|
164
|
+
const file = path.join(dir, 'state.json');
|
|
165
|
+
const storage = new JsonStorage(file);
|
|
166
|
+
await storage.init();
|
|
167
|
+
const ledger = new LedgerEngine(storage, defaultConfig);
|
|
168
|
+
const engine = new JobEngine(storage, ledger, defaultConfig);
|
|
169
|
+
return { engine, ledger };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function loadScript(scriptPath: string): Promise<GenerationScript> {
|
|
173
|
+
const absolutePath = path.isAbsolute(scriptPath) ? scriptPath : path.resolve(process.cwd(), scriptPath);
|
|
174
|
+
const moduleUrl = pathToFileURL(absolutePath).toString();
|
|
175
|
+
const mod = await import(moduleUrl);
|
|
176
|
+
const script = (mod.default || mod.script || mod) as GenerationScript;
|
|
177
|
+
if (!script?.task || !script?.expectedAnswer || !script?.personas) {
|
|
178
|
+
throw new Error('Generation script must export { task, expectedAnswer, personas }.');
|
|
179
|
+
}
|
|
180
|
+
return script;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function generateResponse(
|
|
184
|
+
script: GenerationScript,
|
|
185
|
+
persona: Persona,
|
|
186
|
+
client: OpenAIClient | null,
|
|
187
|
+
model?: string
|
|
188
|
+
): Promise<string> {
|
|
189
|
+
if (!client) {
|
|
190
|
+
return script.mockResponse ? script.mockResponse(persona, script.task, script.expectedAnswer) : script.expectedAnswer;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const prompt = script.buildPrompt
|
|
194
|
+
? script.buildPrompt(persona, script.task, script.expectedAnswer)
|
|
195
|
+
: defaultPrompt(persona, script.task, script.expectedAnswer);
|
|
196
|
+
|
|
197
|
+
const raw = await client.generate({
|
|
198
|
+
model: model || 'gpt-5.2',
|
|
199
|
+
system: prompt.system,
|
|
200
|
+
user: prompt.user,
|
|
201
|
+
temperature: 0
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
const parsed = JSON.parse(raw);
|
|
206
|
+
if (typeof parsed.answer === 'string') return parsed.answer;
|
|
207
|
+
} catch {
|
|
208
|
+
// ignore
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return raw;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function defaultPrompt(persona: Persona, task: GenerationTask, expectedAnswer: string) {
|
|
215
|
+
return {
|
|
216
|
+
system: persona.systemPrompt,
|
|
217
|
+
user: [
|
|
218
|
+
'Return JSON: {"answer": string}',
|
|
219
|
+
'',
|
|
220
|
+
`TASK: ${task.title}`,
|
|
221
|
+
task.desc ? `DESC: ${task.desc}` : '',
|
|
222
|
+
'',
|
|
223
|
+
`INPUT:\n${task.input}`,
|
|
224
|
+
'',
|
|
225
|
+
`EXPECTED: ${expectedAnswer}`
|
|
226
|
+
]
|
|
227
|
+
.filter(Boolean)
|
|
228
|
+
.join('\n')
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
type OpenAIClient = {
|
|
233
|
+
generate(input: { model: string; system: string; user: string; temperature: number }): Promise<string>;
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
function createOpenAIClient(apiKey: string): OpenAIClient {
|
|
237
|
+
const client = new OpenAI({ apiKey });
|
|
238
|
+
return {
|
|
239
|
+
async generate(input) {
|
|
240
|
+
const res = await client.chat.completions.create({
|
|
241
|
+
model: input.model,
|
|
242
|
+
messages: [
|
|
243
|
+
{ role: 'system', content: input.system },
|
|
244
|
+
{ role: 'user', content: input.user }
|
|
245
|
+
],
|
|
246
|
+
temperature: input.temperature
|
|
247
|
+
});
|
|
248
|
+
return String(res.choices?.[0]?.message?.content || '').trim();
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
}
|
/package/{LICENSE → LICENSE.txt}
RENAMED
|
File without changes
|