@consensus-tools/consensus-tools 0.1.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/src/config.ts ADDED
@@ -0,0 +1,306 @@
1
+ import Ajv from 'ajv';
2
+ import { deepCopy } from './util/ids';
3
+ import type { ConsensusToolsConfig } from './types';
4
+
5
+ export const PLUGIN_ID = 'consensus-tools';
6
+
7
+ export const configSchema = {
8
+ type: 'object',
9
+ additionalProperties: false,
10
+ properties: {
11
+ mode: { type: 'string', enum: ['local', 'global'], default: 'local' },
12
+ local: {
13
+ type: 'object',
14
+ additionalProperties: false,
15
+ properties: {
16
+ storage: {
17
+ type: 'object',
18
+ additionalProperties: false,
19
+ properties: {
20
+ kind: { type: 'string', enum: ['sqlite', 'json'], default: 'json' },
21
+ path: { type: 'string', default: './.openclaw/consensus-tools.json' }
22
+ },
23
+ required: ['kind', 'path']
24
+ },
25
+ server: {
26
+ type: 'object',
27
+ additionalProperties: false,
28
+ properties: {
29
+ enabled: { type: 'boolean', default: false },
30
+ host: { type: 'string', default: '127.0.0.1' },
31
+ port: { type: 'integer', default: 9888, minimum: 1, maximum: 65535 },
32
+ authToken: { type: 'string', default: '' }
33
+ },
34
+ required: ['enabled', 'host', 'port', 'authToken']
35
+ },
36
+ slashingEnabled: { type: 'boolean', default: false },
37
+ jobDefaults: {
38
+ type: 'object',
39
+ additionalProperties: false,
40
+ properties: {
41
+ reward: { type: 'number', default: 10, minimum: 0 },
42
+ stakeRequired: { type: 'number', default: 1, minimum: 0 },
43
+ maxParticipants: { type: 'integer', default: 3, minimum: 1 },
44
+ minParticipants: { type: 'integer', default: 1, minimum: 1 },
45
+ expiresSeconds: { type: 'integer', default: 86400, minimum: 60 },
46
+ consensusPolicy: {
47
+ type: 'object',
48
+ additionalProperties: false,
49
+ properties: {
50
+ type: {
51
+ type: 'string',
52
+ enum: [
53
+ 'SINGLE_WINNER',
54
+ 'HIGHEST_CONFIDENCE_SINGLE',
55
+ 'OWNER_PICK',
56
+ 'TOP_K_SPLIT',
57
+ 'MAJORITY_VOTE',
58
+ 'WEIGHTED_VOTE_SIMPLE',
59
+ 'WEIGHTED_REPUTATION',
60
+ 'TRUSTED_ARBITER'
61
+ ],
62
+ default: 'SINGLE_WINNER'
63
+ },
64
+ trustedArbiterAgentId: { type: 'string', default: '' },
65
+ minConfidence: { type: 'number', default: 0, minimum: 0, maximum: 1 },
66
+ topK: { type: 'integer', default: 2, minimum: 1 },
67
+ ordering: { type: 'string', enum: ['confidence', 'score'], default: 'confidence' },
68
+ quorum: { type: 'integer', minimum: 1 }
69
+ },
70
+ required: ['type', 'trustedArbiterAgentId']
71
+ },
72
+ slashingPolicy: {
73
+ type: 'object',
74
+ additionalProperties: false,
75
+ properties: {
76
+ enabled: { type: 'boolean', default: false },
77
+ slashPercent: { type: 'number', default: 0, minimum: 0, maximum: 1 },
78
+ slashFlat: { type: 'number', default: 0, minimum: 0 }
79
+ },
80
+ required: ['enabled', 'slashPercent', 'slashFlat']
81
+ }
82
+ },
83
+ required: [
84
+ 'reward',
85
+ 'stakeRequired',
86
+ 'maxParticipants',
87
+ 'minParticipants',
88
+ 'expiresSeconds',
89
+ 'consensusPolicy',
90
+ 'slashingPolicy'
91
+ ]
92
+ },
93
+ consensusPolicies: {
94
+ type: 'object',
95
+ additionalProperties: {
96
+ type: 'object',
97
+ additionalProperties: false,
98
+ properties: {
99
+ type: {
100
+ type: 'string',
101
+ enum: [
102
+ 'SINGLE_WINNER',
103
+ 'HIGHEST_CONFIDENCE_SINGLE',
104
+ 'OWNER_PICK',
105
+ 'TOP_K_SPLIT',
106
+ 'MAJORITY_VOTE',
107
+ 'WEIGHTED_VOTE_SIMPLE',
108
+ 'WEIGHTED_REPUTATION',
109
+ 'TRUSTED_ARBITER'
110
+ ]
111
+ },
112
+ trustedArbiterAgentId: { type: 'string' },
113
+ minConfidence: { type: 'number', minimum: 0, maximum: 1 },
114
+ topK: { type: 'integer', minimum: 1 },
115
+ ordering: { type: 'string', enum: ['confidence', 'score'] },
116
+ quorum: { type: 'integer', minimum: 1 }
117
+ },
118
+ required: ['type']
119
+ },
120
+ default: {}
121
+ },
122
+ ledger: {
123
+ type: 'object',
124
+ additionalProperties: false,
125
+ properties: {
126
+ faucetEnabled: { type: 'boolean', default: false },
127
+ initialCreditsPerAgent: { type: 'number', default: 0, minimum: 0 },
128
+ balancesMode: { type: 'string', enum: ['initial', 'override'], default: 'initial' },
129
+ balances: {
130
+ type: 'object',
131
+ additionalProperties: { type: 'number', minimum: 0 },
132
+ default: {}
133
+ }
134
+ },
135
+ required: ['faucetEnabled', 'initialCreditsPerAgent', 'balancesMode', 'balances']
136
+ }
137
+ },
138
+ required: ['storage', 'server', 'slashingEnabled', 'jobDefaults', 'ledger']
139
+ },
140
+ global: {
141
+ type: 'object',
142
+ additionalProperties: false,
143
+ properties: {
144
+ baseUrl: { type: 'string', default: 'http://localhost:9888' },
145
+ accessToken: { type: 'string', default: '' }
146
+ },
147
+ required: ['baseUrl', 'accessToken']
148
+ },
149
+ agentIdentity: {
150
+ type: 'object',
151
+ additionalProperties: false,
152
+ properties: {
153
+ agentIdSource: { type: 'string', enum: ['openclaw', 'env', 'manual'], default: 'openclaw' },
154
+ manualAgentId: { type: 'string', default: '' }
155
+ },
156
+ required: ['agentIdSource', 'manualAgentId']
157
+ },
158
+ safety: {
159
+ type: 'object',
160
+ additionalProperties: false,
161
+ properties: {
162
+ requireOptionalToolsOptIn: { type: 'boolean', default: true },
163
+ allowNetworkSideEffects: { type: 'boolean', default: false }
164
+ },
165
+ required: ['requireOptionalToolsOptIn', 'allowNetworkSideEffects']
166
+ }
167
+ },
168
+ required: ['mode', 'agentIdentity', 'safety'],
169
+ allOf: [
170
+ {
171
+ if: { properties: { mode: { const: 'local' } } },
172
+ then: { required: ['local'] }
173
+ },
174
+ {
175
+ if: { properties: { mode: { const: 'global' } } },
176
+ then: { required: ['global'] }
177
+ }
178
+ ]
179
+ } as const;
180
+
181
+ export const defaultConfig: ConsensusToolsConfig = {
182
+ mode: 'local',
183
+ local: {
184
+ storage: { kind: 'json', path: './.openclaw/consensus-tools.json' },
185
+ server: { enabled: false, host: '127.0.0.1', port: 9888, authToken: '' },
186
+ slashingEnabled: false,
187
+ jobDefaults: {
188
+ reward: 10,
189
+ stakeRequired: 1,
190
+ maxParticipants: 3,
191
+ minParticipants: 1,
192
+ expiresSeconds: 86400,
193
+ consensusPolicy: { type: 'SINGLE_WINNER', trustedArbiterAgentId: '' },
194
+ slashingPolicy: { enabled: false, slashPercent: 0, slashFlat: 0 }
195
+ },
196
+ consensusPolicies: {
197
+ SINGLE_WINNER: { type: 'SINGLE_WINNER' },
198
+ HIGHEST_CONFIDENCE_SINGLE: { type: 'HIGHEST_CONFIDENCE_SINGLE', minConfidence: 0 },
199
+ OWNER_PICK: { type: 'OWNER_PICK' },
200
+ TOP_K_SPLIT: { type: 'TOP_K_SPLIT', topK: 2, ordering: 'confidence' },
201
+ MAJORITY_VOTE: { type: 'MAJORITY_VOTE' },
202
+ WEIGHTED_VOTE_SIMPLE: { type: 'WEIGHTED_VOTE_SIMPLE' },
203
+ WEIGHTED_REPUTATION: { type: 'WEIGHTED_REPUTATION' },
204
+ TRUSTED_ARBITER: { type: 'TRUSTED_ARBITER', trustedArbiterAgentId: '' }
205
+ },
206
+ ledger: {
207
+ faucetEnabled: false,
208
+ initialCreditsPerAgent: 0,
209
+ balancesMode: 'initial',
210
+ balances: {}
211
+ }
212
+ },
213
+ global: {
214
+ baseUrl: 'http://localhost:9888',
215
+ accessToken: ''
216
+ },
217
+ agentIdentity: { agentIdSource: 'openclaw', manualAgentId: '' },
218
+ safety: {
219
+ requireOptionalToolsOptIn: true,
220
+ allowNetworkSideEffects: false
221
+ }
222
+ };
223
+
224
+ const ajv = new Ajv({ allErrors: true, useDefaults: true, removeAdditional: false });
225
+ const validate = ajv.compile(configSchema);
226
+
227
+ export function loadConfig(api: any, logger?: any): ConsensusToolsConfig {
228
+ const fallback = deepCopy(defaultConfig);
229
+ let raw: any = undefined;
230
+
231
+ try {
232
+ raw = api?.config?.getPluginConfig?.(PLUGIN_ID);
233
+ } catch (err) {
234
+ logger?.warn?.({ err }, 'consensus-tools: failed to read config via getPluginConfig');
235
+ }
236
+
237
+ if (!raw) {
238
+ try {
239
+ raw = api?.config?.get?.(`plugins.entries.${PLUGIN_ID}.config`);
240
+ } catch (err) {
241
+ logger?.warn?.({ err }, 'consensus-tools: failed to read config via config.get');
242
+ }
243
+ }
244
+
245
+ if (!raw) {
246
+ raw = api?.config?.plugins?.entries?.[PLUGIN_ID]?.config;
247
+ }
248
+
249
+ if (!raw) {
250
+ raw = api?.config?.entries?.[PLUGIN_ID]?.config;
251
+ }
252
+
253
+ return mergeDefaults(fallback, raw ?? {});
254
+ }
255
+
256
+ export function validateConfig(input: ConsensusToolsConfig, logger?: any): { config: ConsensusToolsConfig; errors: string[] } {
257
+ const candidate = deepCopy(input);
258
+ const ok = validate(candidate);
259
+ const errors = ok
260
+ ? []
261
+ : (validate.errors || []).map((err) => `${err.instancePath || '/'} ${err.message || 'invalid'}`);
262
+
263
+ if (!ok) {
264
+ logger?.warn?.({ errors }, 'consensus-tools: config validation warnings');
265
+ }
266
+
267
+ return { config: candidate, errors };
268
+ }
269
+
270
+ export function mergeDefaults<T>(defaults: T, input: Partial<T>): T {
271
+ if (Array.isArray(defaults)) {
272
+ return (Array.isArray(input) ? input : defaults) as T;
273
+ }
274
+ if (defaults && typeof defaults === 'object') {
275
+ const output: any = {};
276
+ const keys = new Set([...Object.keys(defaults as object), ...Object.keys((input || {}) as object)]);
277
+ for (const key of keys) {
278
+ const defVal: any = (defaults as any)[key];
279
+ const inVal: any = (input as any)?.[key];
280
+ if (inVal === undefined) {
281
+ output[key] = deepCopy(defVal);
282
+ } else {
283
+ output[key] = mergeDefaults(defVal, inVal);
284
+ }
285
+ }
286
+ return output as T;
287
+ }
288
+ return (input === undefined ? defaults : input) as T;
289
+ }
290
+
291
+ export function resolveAgentId(api: any, config: ConsensusToolsConfig): string {
292
+ if (config.agentIdentity.agentIdSource === 'manual' && config.agentIdentity.manualAgentId) {
293
+ return config.agentIdentity.manualAgentId;
294
+ }
295
+ if (config.agentIdentity.agentIdSource === 'env') {
296
+ const envId = process.env.OPENCLAW_AGENT_ID || process.env.CONSENSUS_TOOLS_AGENT_ID;
297
+ if (envId) return envId;
298
+ }
299
+ return (
300
+ api?.agentId ||
301
+ api?.identity?.agentId ||
302
+ api?.context?.agentId ||
303
+ config.agentIdentity.manualAgentId ||
304
+ 'unknown-agent'
305
+ );
306
+ }
@@ -0,0 +1,191 @@
1
+ import type { ConsensusPolicyType, Job, Submission, Vote } from '../types';
2
+
3
+ export interface ConsensusResult {
4
+ winners: string[];
5
+ winningSubmissionIds: string[];
6
+ consensusTrace: Record<string, unknown>;
7
+ finalArtifact: Record<string, unknown> | null;
8
+ }
9
+
10
+ export interface ConsensusInput {
11
+ job: Job;
12
+ submissions: Submission[];
13
+ votes: Vote[];
14
+ reputation: (agentId: string) => number;
15
+ manualWinnerAgentIds?: string[];
16
+ manualSubmissionId?: string;
17
+ }
18
+
19
+ export function resolveConsensus(input: ConsensusInput): ConsensusResult {
20
+ const policy = input.job.consensusPolicy.type as ConsensusPolicyType;
21
+ if (policy === 'TRUSTED_ARBITER') {
22
+ if (input.manualWinnerAgentIds && input.manualWinnerAgentIds.length) {
23
+ return {
24
+ winners: input.manualWinnerAgentIds,
25
+ winningSubmissionIds: input.manualSubmissionId ? [input.manualSubmissionId] : [],
26
+ consensusTrace: { policy, mode: 'manual' },
27
+ finalArtifact: findArtifact(input, input.manualSubmissionId)
28
+ };
29
+ }
30
+ return {
31
+ winners: [],
32
+ winningSubmissionIds: [],
33
+ consensusTrace: { policy, mode: 'awaiting_arbiter' },
34
+ finalArtifact: null
35
+ };
36
+ }
37
+
38
+ if (policy === 'OWNER_PICK') {
39
+ if (input.manualWinnerAgentIds && input.manualWinnerAgentIds.length) {
40
+ return {
41
+ winners: input.manualWinnerAgentIds,
42
+ winningSubmissionIds: input.manualSubmissionId ? [input.manualSubmissionId] : [],
43
+ consensusTrace: { policy, mode: 'manual' },
44
+ finalArtifact: findArtifact(input, input.manualSubmissionId)
45
+ };
46
+ }
47
+ return {
48
+ winners: [],
49
+ winningSubmissionIds: [],
50
+ consensusTrace: { policy, reason: 'no_owner_selection' },
51
+ finalArtifact: null
52
+ };
53
+ }
54
+
55
+ if (!input.submissions.length) {
56
+ return { winners: [], winningSubmissionIds: [], consensusTrace: { policy, reason: 'no_submissions' }, finalArtifact: null };
57
+ }
58
+
59
+ if (policy === 'SINGLE_WINNER') {
60
+ const sorted = [...input.submissions].sort((a, b) => Date.parse(a.submittedAt) - Date.parse(b.submittedAt));
61
+ const winner = sorted[0];
62
+ return {
63
+ winners: [winner.agentId],
64
+ winningSubmissionIds: [winner.id],
65
+ consensusTrace: { policy, method: 'first_submission' },
66
+ finalArtifact: winner.artifacts
67
+ };
68
+ }
69
+
70
+ if (policy === 'HIGHEST_CONFIDENCE_SINGLE') {
71
+ const minConfidence = input.job.consensusPolicy.minConfidence ?? 0;
72
+ const sorted = [...input.submissions]
73
+ .filter((sub) => sub.confidence >= minConfidence)
74
+ .sort((a, b) => {
75
+ if (b.confidence === a.confidence) {
76
+ return Date.parse(a.submittedAt) - Date.parse(b.submittedAt);
77
+ }
78
+ return b.confidence - a.confidence;
79
+ });
80
+ const winner = sorted[0];
81
+ if (!winner) {
82
+ return {
83
+ winners: [],
84
+ winningSubmissionIds: [],
85
+ consensusTrace: { policy, reason: 'min_confidence_not_met', minConfidence },
86
+ finalArtifact: null
87
+ };
88
+ }
89
+ return {
90
+ winners: [winner.agentId],
91
+ winningSubmissionIds: [winner.id],
92
+ consensusTrace: { policy, minConfidence, method: 'highest_confidence' },
93
+ finalArtifact: winner.artifacts
94
+ };
95
+ }
96
+
97
+ if (policy === 'TOP_K_SPLIT') {
98
+ const ordering = input.job.consensusPolicy.ordering ?? 'confidence';
99
+ const topK = Math.max(1, input.job.consensusPolicy.topK ?? 2);
100
+ const scores: Record<string, number> = {};
101
+
102
+ if (ordering === 'score') {
103
+ for (const vote of input.votes) {
104
+ if (!vote.submissionId) continue;
105
+ const weight = vote.weight ?? vote.score ?? 1;
106
+ scores[vote.submissionId] = (scores[vote.submissionId] || 0) + (vote.score ?? 1) * weight;
107
+ }
108
+ }
109
+
110
+ const ranked = input.submissions
111
+ .map((sub) => ({
112
+ submission: sub,
113
+ metric: ordering === 'score' ? scores[sub.id] || 0 : sub.confidence
114
+ }))
115
+ .sort((a, b) => {
116
+ if (b.metric === a.metric) {
117
+ return Date.parse(a.submission.submittedAt) - Date.parse(b.submission.submittedAt);
118
+ }
119
+ return b.metric - a.metric;
120
+ })
121
+ .slice(0, topK);
122
+
123
+ if (!ranked.length) {
124
+ return {
125
+ winners: [],
126
+ winningSubmissionIds: [],
127
+ consensusTrace: { policy, ordering, topK, reason: 'no_submissions' },
128
+ finalArtifact: null
129
+ };
130
+ }
131
+
132
+ return {
133
+ winners: ranked.map((entry) => entry.submission.agentId),
134
+ winningSubmissionIds: ranked.map((entry) => entry.submission.id),
135
+ consensusTrace: { policy, ordering, topK, scores },
136
+ finalArtifact: ranked[0].submission.artifacts
137
+ };
138
+ }
139
+
140
+ const quorum = input.job.consensusPolicy.quorum;
141
+ if (quorum && input.votes.length < quorum) {
142
+ return {
143
+ winners: [],
144
+ winningSubmissionIds: [],
145
+ consensusTrace: { policy, reason: 'quorum_not_met', quorum, votes: input.votes.length },
146
+ finalArtifact: null
147
+ };
148
+ }
149
+
150
+ const scores: Record<string, number> = {};
151
+ const voteCounts: Record<string, number> = {};
152
+
153
+ for (const vote of input.votes) {
154
+ let weight = 1;
155
+ if (policy === 'WEIGHTED_REPUTATION') {
156
+ weight = input.reputation(vote.agentId);
157
+ } else if (policy === 'WEIGHTED_VOTE_SIMPLE') {
158
+ weight = vote.weight ?? vote.score ?? 1;
159
+ }
160
+ if (vote.submissionId) {
161
+ scores[vote.submissionId] = (scores[vote.submissionId] || 0) + (vote.score ?? 1) * weight;
162
+ voteCounts[vote.submissionId] = (voteCounts[vote.submissionId] || 0) + 1;
163
+ }
164
+ }
165
+
166
+ const best = input.submissions
167
+ .map((sub) => ({
168
+ submission: sub,
169
+ score: scores[sub.id] || 0,
170
+ votes: voteCounts[sub.id] || 0
171
+ }))
172
+ .sort((a, b) => {
173
+ if (b.score === a.score) {
174
+ return Date.parse(a.submission.submittedAt) - Date.parse(b.submission.submittedAt);
175
+ }
176
+ return b.score - a.score;
177
+ })[0];
178
+
179
+ return {
180
+ winners: [best.submission.agentId],
181
+ winningSubmissionIds: [best.submission.id],
182
+ consensusTrace: { policy, scores, voteCounts },
183
+ finalArtifact: best.submission.artifacts
184
+ };
185
+ }
186
+
187
+ function findArtifact(input: ConsensusInput, submissionId?: string): Record<string, unknown> | null {
188
+ if (!submissionId) return null;
189
+ const match = input.submissions.find((sub) => sub.id === submissionId);
190
+ return match?.artifacts || null;
191
+ }
@@ -0,0 +1,11 @@
1
+ import type { Job } from '../types';
2
+
3
+ export interface EligibilityResult {
4
+ ok: boolean;
5
+ reason?: string;
6
+ }
7
+
8
+ export function checkEligibility(job: Job): EligibilityResult {
9
+ if (job.status !== 'OPEN') return { ok: false, reason: 'Job not open' };
10
+ return { ok: true };
11
+ }