@sns-myagent/cli 0.2.0 → 0.3.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/CHANGELOG.md +12 -1
- package/README.md +7 -8
- package/package.json +3 -3
- package/scripts/apply-pi-natives-patch.js +82 -56
- package/src/adapters/telegram/bot.ts +41 -1
- package/src/adapters/telegram/bridge.ts +186 -0
- package/src/adapters/telegram/handler.ts +138 -13
- package/src/adapters/telegram/index.ts +12 -2
- package/src/agents/__tests__/config.test.ts +79 -0
- package/src/agents/__tests__/resilience.test.ts +119 -0
- package/src/agents/__tests__/strategies.test.ts +83 -0
- package/src/agents/config.ts +367 -0
- package/src/agents/ensemble.ts +224 -0
- package/src/agents/resilience.ts +332 -0
- package/src/agents/strategies/best-of-n.ts +108 -0
- package/src/agents/strategies/consensus.ts +105 -0
- package/src/agents/strategies/critic.ts +131 -0
- package/src/agents/strategies/types.ts +68 -0
- package/src/async/__tests__/task-runner.test.ts +162 -0
- package/src/async/__tests__/task-store.test.ts +146 -0
- package/src/async/index.ts +28 -1
- package/src/async/notifier.ts +133 -0
- package/src/async/task-runner.ts +175 -0
- package/src/async/task-store.ts +170 -0
- package/src/async/types.ts +70 -0
- package/src/cli/entry.ts +3 -1
- package/src/cli/index.ts +69 -54
- package/src/config/index.ts +1 -1
- package/src/debug/index.ts +1 -1
- package/src/modes/components/welcome.ts +13 -6
- package/src/modes/controllers/event-controller.ts +1 -1
- package/src/modes/setup-wizard/scenes/splash.ts +1 -1
- package/src/session/agent-session.ts +1 -1
- package/src/slash-commands/builtin-registry.ts +63 -0
- package/src/slash-commands/helpers/task.ts +181 -0
- package/src/tbm/__tests__/tbm.test.ts +660 -0
- package/src/tbm/comm-modes.ts +165 -0
- package/src/tbm/config.ts +136 -0
- package/src/tbm/context-delta.ts +146 -0
- package/src/tbm/context-pyramid.ts +202 -0
- package/src/tbm/dashboard.ts +131 -0
- package/src/tbm/index.ts +247 -0
- package/src/tbm/lazy-skills.ts +182 -0
- package/src/tbm/response-cache.ts +220 -0
- package/src/tbm/tombstone.ts +189 -0
- package/src/tbm/tool-compress.ts +230 -0
- package/src/tools/ask.ts +1 -1
- package/src/tui/chat-blocks.ts +205 -0
- package/src/tui/chat-ui.ts +270 -0
- package/src/tui/code-cell.ts +90 -1
- package/src/tui/command-palette.ts +189 -0
- package/src/tui/index.ts +4 -0
- package/src/tui/splash.ts +130 -0
- package/src/ui/banner.ts +69 -29
- package/src/ui/colors.ts +24 -0
- package/src/ui/error-display.ts +130 -0
- package/src/ui/gradient.ts +104 -0
- package/src/ui/index.ts +15 -0
- package/src/ui/memory-toast.ts +102 -0
- package/src/ui/status-bar.ts +36 -30
- package/bin/snscoder.js +0 -98
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error resilience — SNS-MyAgent Phase 5.4
|
|
3
|
+
*
|
|
4
|
+
* Retry with exponential backoff, per-task timeout, circuit breaker,
|
|
5
|
+
* and fallback model chain.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { EventEmitter } from "node:events";
|
|
9
|
+
|
|
10
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export interface RetryOptions {
|
|
13
|
+
/** Max attempts (default: 3) */
|
|
14
|
+
maxAttempts: number;
|
|
15
|
+
/** Base delay in ms (default: 1000) */
|
|
16
|
+
baseDelayMs: number;
|
|
17
|
+
/** Max delay in ms (default: 30000) */
|
|
18
|
+
maxDelayMs: number;
|
|
19
|
+
/** Backoff multiplier (default: 2) */
|
|
20
|
+
backoffMultiplier: number;
|
|
21
|
+
/** Jitter factor 0-1 (default: 0.2) */
|
|
22
|
+
jitterFactor: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface CircuitBreakerOptions {
|
|
26
|
+
/** Failures before opening (default: 3) */
|
|
27
|
+
failureThreshold: number;
|
|
28
|
+
/** Time in ms before half-open (default: 60000) */
|
|
29
|
+
resetTimeoutMs: number;
|
|
30
|
+
/** Successes in half-open to close (default: 2) */
|
|
31
|
+
successThreshold: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type CircuitState = "closed" | "open" | "half_open";
|
|
35
|
+
|
|
36
|
+
export interface TaskResult<T> {
|
|
37
|
+
success: boolean;
|
|
38
|
+
result?: T;
|
|
39
|
+
error?: Error;
|
|
40
|
+
attempts: number;
|
|
41
|
+
totalTimeMs: number;
|
|
42
|
+
fromCache?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ─── Retry with Exponential Backoff ──────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
const DEFAULT_RETRY: RetryOptions = {
|
|
48
|
+
maxAttempts: 3,
|
|
49
|
+
baseDelayMs: 1_000,
|
|
50
|
+
maxDelayMs: 30_000,
|
|
51
|
+
backoffMultiplier: 2,
|
|
52
|
+
jitterFactor: 0.2,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export async function withRetry<T>(
|
|
56
|
+
fn: () => Promise<T>,
|
|
57
|
+
options: Partial<RetryOptions> = {},
|
|
58
|
+
): Promise<TaskResult<T>> {
|
|
59
|
+
const opts = { ...DEFAULT_RETRY, ...options };
|
|
60
|
+
const startTime = Date.now();
|
|
61
|
+
let lastError: Error | undefined;
|
|
62
|
+
|
|
63
|
+
for (let attempt = 1; attempt <= opts.maxAttempts; attempt++) {
|
|
64
|
+
try {
|
|
65
|
+
const result = await fn();
|
|
66
|
+
return {
|
|
67
|
+
success: true,
|
|
68
|
+
result,
|
|
69
|
+
attempts: attempt,
|
|
70
|
+
totalTimeMs: Date.now() - startTime,
|
|
71
|
+
};
|
|
72
|
+
} catch (err) {
|
|
73
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
74
|
+
|
|
75
|
+
if (attempt < opts.maxAttempts) {
|
|
76
|
+
// Exponential backoff with jitter
|
|
77
|
+
const baseDelay = opts.baseDelayMs * Math.pow(opts.backoffMultiplier, attempt - 1);
|
|
78
|
+
const jitter = baseDelay * opts.jitterFactor * (Math.random() * 2 - 1);
|
|
79
|
+
const delay = Math.min(Math.max(0, baseDelay + jitter), opts.maxDelayMs);
|
|
80
|
+
await sleep(delay);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
success: false,
|
|
87
|
+
error: lastError,
|
|
88
|
+
attempts: opts.maxAttempts,
|
|
89
|
+
totalTimeMs: Date.now() - startTime,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ─── Circuit Breaker ─────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
const DEFAULT_CIRCUIT: CircuitBreakerOptions = {
|
|
96
|
+
failureThreshold: 3,
|
|
97
|
+
resetTimeoutMs: 60_000,
|
|
98
|
+
successThreshold: 2,
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export class CircuitBreaker extends EventEmitter {
|
|
102
|
+
readonly #key: string;
|
|
103
|
+
readonly #options: CircuitBreakerOptions;
|
|
104
|
+
#state: CircuitState = "closed";
|
|
105
|
+
#failureCount = 0;
|
|
106
|
+
#successCount = 0;
|
|
107
|
+
#lastFailureTime = 0;
|
|
108
|
+
#nextAttemptTime = 0;
|
|
109
|
+
|
|
110
|
+
constructor(key: string, options: Partial<CircuitBreakerOptions> = {}) {
|
|
111
|
+
super();
|
|
112
|
+
this.#key = key;
|
|
113
|
+
this.#options = { ...DEFAULT_CIRCUIT, ...options };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
get key(): string { return this.#key; }
|
|
117
|
+
get state(): CircuitState { return this.#state; }
|
|
118
|
+
get failureCount(): number { return this.#failureCount; }
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Check if the circuit allows execution.
|
|
122
|
+
*/
|
|
123
|
+
canExecute(): boolean {
|
|
124
|
+
if (this.#state === "closed") return true;
|
|
125
|
+
|
|
126
|
+
if (this.#state === "open") {
|
|
127
|
+
if (Date.now() >= this.#nextAttemptTime) {
|
|
128
|
+
this.#transitionTo("half_open");
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// half_open: allow one attempt
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Record a successful execution.
|
|
140
|
+
*/
|
|
141
|
+
recordSuccess(): void {
|
|
142
|
+
if (this.#state === "half_open") {
|
|
143
|
+
this.#successCount++;
|
|
144
|
+
if (this.#successCount >= this.#options.successThreshold) {
|
|
145
|
+
this.#transitionTo("closed");
|
|
146
|
+
}
|
|
147
|
+
} else {
|
|
148
|
+
this.#failureCount = 0;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Record a failed execution.
|
|
154
|
+
*/
|
|
155
|
+
recordFailure(): void {
|
|
156
|
+
this.#lastFailureTime = Date.now();
|
|
157
|
+
|
|
158
|
+
if (this.#state === "half_open") {
|
|
159
|
+
this.#transitionTo("open");
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
this.#failureCount++;
|
|
164
|
+
if (this.#failureCount >= this.#options.failureThreshold) {
|
|
165
|
+
this.#transitionTo("open");
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Execute a function through the circuit breaker.
|
|
171
|
+
*/
|
|
172
|
+
async execute<T>(fn: () => Promise<T>): Promise<T> {
|
|
173
|
+
if (!this.canExecute()) {
|
|
174
|
+
throw new CircuitBreakerOpenError(
|
|
175
|
+
`Circuit breaker [${this.#key}] is OPEN. Retry after ${new Date(this.#nextAttemptTime).toISOString()}`
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
const result = await fn();
|
|
181
|
+
this.recordSuccess();
|
|
182
|
+
return result;
|
|
183
|
+
} catch (err) {
|
|
184
|
+
this.recordFailure();
|
|
185
|
+
throw err;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Reset circuit breaker to closed state.
|
|
191
|
+
*/
|
|
192
|
+
reset(): void {
|
|
193
|
+
this.#state = "closed";
|
|
194
|
+
this.#failureCount = 0;
|
|
195
|
+
this.#successCount = 0;
|
|
196
|
+
this.#lastFailureTime = 0;
|
|
197
|
+
this.#nextAttemptTime = 0;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Get stats for display */
|
|
201
|
+
stats(): { key: string; state: CircuitState; failures: number; nextAttempt?: string } {
|
|
202
|
+
return {
|
|
203
|
+
key: this.#key,
|
|
204
|
+
state: this.#state,
|
|
205
|
+
failures: this.#failureCount,
|
|
206
|
+
nextAttempt: this.#state === "open" ? new Date(this.#nextAttemptTime).toISOString() : undefined,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
#transitionTo(state: CircuitState): void {
|
|
211
|
+
const prev = this.#state;
|
|
212
|
+
this.#state = state;
|
|
213
|
+
|
|
214
|
+
if (state === "open") {
|
|
215
|
+
this.#nextAttemptTime = Date.now() + this.#options.resetTimeoutMs;
|
|
216
|
+
this.#successCount = 0;
|
|
217
|
+
}
|
|
218
|
+
if (state === "closed") {
|
|
219
|
+
this.#failureCount = 0;
|
|
220
|
+
this.#successCount = 0;
|
|
221
|
+
this.#nextAttemptTime = 0;
|
|
222
|
+
}
|
|
223
|
+
if (state === "half_open") {
|
|
224
|
+
this.#successCount = 0;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
this.emit("state_change", { key: this.#key, from: prev, to: state });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ─── Circuit Breaker Registry ────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
const breakers = new Map<string, CircuitBreaker>();
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Get or create a circuit breaker for a provider/model key.
|
|
237
|
+
*/
|
|
238
|
+
export function getCircuitBreaker(key: string, options?: Partial<CircuitBreakerOptions>): CircuitBreaker {
|
|
239
|
+
if (!breakers.has(key)) {
|
|
240
|
+
breakers.set(key, new CircuitBreaker(key, options));
|
|
241
|
+
}
|
|
242
|
+
return breakers.get(key)!;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Get all circuit breaker stats.
|
|
247
|
+
*/
|
|
248
|
+
export function getAllCircuitBreakerStats(): Array<{ key: string; state: CircuitState; failures: number; nextAttempt?: string }> {
|
|
249
|
+
return [...breakers.values()].map(b => b.stats());
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ─── Task Timeout ────────────────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Execute with timeout. Rejects with TimeoutError if exceeded.
|
|
256
|
+
*/
|
|
257
|
+
export async function withTimeout<T>(
|
|
258
|
+
fn: () => Promise<T>,
|
|
259
|
+
timeoutMs: number,
|
|
260
|
+
label = "task",
|
|
261
|
+
): Promise<T> {
|
|
262
|
+
return new Promise<T>((resolve, reject) => {
|
|
263
|
+
const timer = setTimeout(() => {
|
|
264
|
+
reject(new TimeoutError(`[${label}] timed out after ${timeoutMs}ms`));
|
|
265
|
+
}, timeoutMs);
|
|
266
|
+
|
|
267
|
+
fn().then(
|
|
268
|
+
(result) => { clearTimeout(timer); resolve(result); },
|
|
269
|
+
(err) => { clearTimeout(timer); reject(err); },
|
|
270
|
+
);
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ─── Fallback Chain ─────────────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Try primary fn first, then fallbacks in order.
|
|
278
|
+
* Returns first successful result.
|
|
279
|
+
*/
|
|
280
|
+
export async function withFallback<T>(
|
|
281
|
+
fns: Array<() => Promise<T>>,
|
|
282
|
+
options: Partial<RetryOptions> = {},
|
|
283
|
+
): Promise<TaskResult<T>> {
|
|
284
|
+
const startTime = Date.now();
|
|
285
|
+
let lastError: Error | undefined;
|
|
286
|
+
let totalAttempts = 0;
|
|
287
|
+
|
|
288
|
+
for (const fn of fns) {
|
|
289
|
+
const result = await withRetry(fn, { ...options, maxAttempts: 1 });
|
|
290
|
+
totalAttempts += result.attempts;
|
|
291
|
+
|
|
292
|
+
if (result.success) {
|
|
293
|
+
return {
|
|
294
|
+
success: true,
|
|
295
|
+
result: result.result,
|
|
296
|
+
attempts: totalAttempts,
|
|
297
|
+
totalTimeMs: Date.now() - startTime,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
lastError = result.error;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
success: false,
|
|
306
|
+
error: lastError,
|
|
307
|
+
attempts: totalAttempts,
|
|
308
|
+
totalTimeMs: Date.now() - startTime,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ─── Errors ──────────────────────────────────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
export class CircuitBreakerOpenError extends Error {
|
|
315
|
+
constructor(message: string) {
|
|
316
|
+
super(message);
|
|
317
|
+
this.name = "CircuitBreakerOpenError";
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export class TimeoutError extends Error {
|
|
322
|
+
constructor(message: string) {
|
|
323
|
+
super(message);
|
|
324
|
+
this.name = "TimeoutError";
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
329
|
+
|
|
330
|
+
function sleep(ms: number): Promise<void> {
|
|
331
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
332
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-of-N Strategy — SNS-MyAgent Phase 5.3d
|
|
3
|
+
*
|
|
4
|
+
* Generate N candidates from same agent/role → Rank by scoring agent → Pick best.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { AgentResponse, EnsembleResult, EnsembleStrategy } from "./types.js";
|
|
8
|
+
import { aggregateTokens } from "./types.js";
|
|
9
|
+
|
|
10
|
+
export interface BestOfNOptions {
|
|
11
|
+
/** Number of candidates to generate (default: 4) */
|
|
12
|
+
n?: number;
|
|
13
|
+
/** Scorer agent role (default: same as first agent) */
|
|
14
|
+
scorerRole?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class BestOfNStrategy implements EnsembleStrategy {
|
|
18
|
+
readonly name = "best_of_n";
|
|
19
|
+
readonly #n: number;
|
|
20
|
+
readonly #scorerRole?: string;
|
|
21
|
+
|
|
22
|
+
constructor(options: BestOfNOptions = {}) {
|
|
23
|
+
this.#n = options.n ?? 4;
|
|
24
|
+
this.#scorerRole = options.scorerRole;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async execute(
|
|
28
|
+
prompt: string,
|
|
29
|
+
agents: string[],
|
|
30
|
+
executeAgent: (role: string, prompt: string) => Promise<AgentResponse>,
|
|
31
|
+
): Promise<EnsembleResult> {
|
|
32
|
+
if (agents.length === 0) {
|
|
33
|
+
throw new Error("Best-of-N requires at least 1 agent");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const generatorRole = agents[0];
|
|
37
|
+
const scorerRole = this.#scorerRole ?? generatorRole;
|
|
38
|
+
const startTime = Date.now();
|
|
39
|
+
const allResponses: AgentResponse[] = [];
|
|
40
|
+
|
|
41
|
+
// Generate N candidates in parallel
|
|
42
|
+
const candidates = await Promise.all(
|
|
43
|
+
Array.from({ length: this.#n }, (_, i) =>
|
|
44
|
+
executeAgent(generatorRole, `${prompt}\n\n[Candidate ${i + 1}/${this.#n}]`),
|
|
45
|
+
),
|
|
46
|
+
);
|
|
47
|
+
allResponses.push(...candidates);
|
|
48
|
+
|
|
49
|
+
// Score each candidate
|
|
50
|
+
const scoredCandidates = await this.#scoreCandidates(candidates, scorerRole, executeAgent, prompt);
|
|
51
|
+
allResponses.push(...scoredCandidates.scores);
|
|
52
|
+
|
|
53
|
+
// Pick highest scored
|
|
54
|
+
const bestIdx = scoredCandidates.ranking[0];
|
|
55
|
+
const winner = candidates[bestIdx];
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
final: winner.content,
|
|
59
|
+
responses: allResponses,
|
|
60
|
+
strategy: this.name,
|
|
61
|
+
rounds: 2,
|
|
62
|
+
totalTimeMs: Date.now() - startTime,
|
|
63
|
+
totalTokens: aggregateTokens(allResponses),
|
|
64
|
+
winner: generatorRole,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async #scoreCandidates(
|
|
69
|
+
candidates: AgentResponse[],
|
|
70
|
+
scorerRole: string,
|
|
71
|
+
executeAgent: (role: string, prompt: string) => Promise<AgentResponse>,
|
|
72
|
+
originalPrompt: string,
|
|
73
|
+
): Promise<{ scores: AgentResponse[]; ranking: number[] }> {
|
|
74
|
+
const scores: AgentResponse[] = [];
|
|
75
|
+
const ranking = Array.from({ length: candidates.length }, (_, i) => i);
|
|
76
|
+
|
|
77
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
78
|
+
const scorePrompt = this.#buildScorePrompt(originalPrompt, candidates[i].content);
|
|
79
|
+
const scoreResponse = await executeAgent(scorerRole, scorePrompt);
|
|
80
|
+
scores.push(scoreResponse);
|
|
81
|
+
candidates[i].score = this.#extractScore(scoreResponse);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Sort by score descending
|
|
85
|
+
ranking.sort((a, b) => (candidates[b].score ?? 0) - (candidates[a].score ?? 0));
|
|
86
|
+
|
|
87
|
+
return { scores, ranking };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
#buildScorePrompt(originalPrompt: string, candidate: string): string {
|
|
91
|
+
return `Original task: ${originalPrompt}
|
|
92
|
+
|
|
93
|
+
Candidate response to evaluate:
|
|
94
|
+
${candidate}
|
|
95
|
+
|
|
96
|
+
Rate this response 0-1 on: correctness, completeness, clarity, relevance.
|
|
97
|
+
Output format:
|
|
98
|
+
SCORE: <0-1>
|
|
99
|
+
REASONING: <brief>`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
#extractScore(scoreResponse: AgentResponse): number {
|
|
103
|
+
const match = scoreResponse.content.match(/SCORE:\s*([0-9]*\.?[0-9]+)/i);
|
|
104
|
+
if (match) return Math.min(1, Math.max(0, Number.parseFloat(match[1])));
|
|
105
|
+
if (scoreResponse.score !== undefined) return scoreResponse.score;
|
|
106
|
+
return 0.5;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Consensus Strategy — SNS-MyAgent Phase 5.3b
|
|
3
|
+
*
|
|
4
|
+
* Spawn N agents with same prompt → Pick majority/best answer.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { AgentResponse, EnsembleResult, EnsembleStrategy } from "./types.js";
|
|
8
|
+
import { aggregateTokens } from "./types.js";
|
|
9
|
+
|
|
10
|
+
export interface ConsensusOptions {
|
|
11
|
+
/** Number of agents to spawn (default: 3) */
|
|
12
|
+
n?: number;
|
|
13
|
+
/** Consensus threshold 0-1 (default: 0.6) */
|
|
14
|
+
threshold?: number;
|
|
15
|
+
/** Comparison function: "exact" | "semantic" (default: "exact") */
|
|
16
|
+
compareMode?: "exact" | "semantic";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class ConsensusStrategy implements EnsembleStrategy {
|
|
20
|
+
readonly name = "consensus";
|
|
21
|
+
readonly #n: number;
|
|
22
|
+
readonly #threshold: number;
|
|
23
|
+
readonly #compareMode: "exact" | "semantic";
|
|
24
|
+
|
|
25
|
+
constructor(options: ConsensusOptions = {}) {
|
|
26
|
+
this.#n = options.n ?? 3;
|
|
27
|
+
this.#threshold = options.threshold ?? 0.6;
|
|
28
|
+
this.#compareMode = options.compareMode ?? "exact";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async execute(
|
|
32
|
+
prompt: string,
|
|
33
|
+
agents: string[],
|
|
34
|
+
executeAgent: (role: string, prompt: string) => Promise<AgentResponse>,
|
|
35
|
+
): Promise<EnsembleResult> {
|
|
36
|
+
if (agents.length === 0) {
|
|
37
|
+
throw new Error("Consensus requires at least 1 agent");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const startTime = Date.now();
|
|
41
|
+
const allResponses: AgentResponse[] = [];
|
|
42
|
+
|
|
43
|
+
// Spawn N agents in parallel (cycle through available agents)
|
|
44
|
+
const candidates = await Promise.all(
|
|
45
|
+
Array.from({ length: this.#n }, async (_, i) => {
|
|
46
|
+
const role = agents[i % agents.length];
|
|
47
|
+
const response = await executeAgent(role, `${prompt}\n\n[Consensus attempt ${i + 1}/${this.#n}]`);
|
|
48
|
+
allResponses.push(response);
|
|
49
|
+
return response;
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
// Find consensus
|
|
54
|
+
const { winner, agreement } = this.#findConsensus(candidates);
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
final: winner.content,
|
|
58
|
+
responses: allResponses,
|
|
59
|
+
strategy: this.name,
|
|
60
|
+
rounds: 1,
|
|
61
|
+
totalTimeMs: Date.now() - startTime,
|
|
62
|
+
totalTokens: aggregateTokens(allResponses),
|
|
63
|
+
winner: winner.role,
|
|
64
|
+
metadata: { agreement, threshold: this.#threshold, met: agreement >= this.#threshold },
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
#findConsensus(candidates: AgentResponse[]): { winner: AgentResponse; agreement: number } {
|
|
69
|
+
if (candidates.length === 1) return { winner: candidates[0], agreement: 1 };
|
|
70
|
+
|
|
71
|
+
// Group similar responses
|
|
72
|
+
const groups: AgentResponse[][] = [];
|
|
73
|
+
|
|
74
|
+
for (const candidate of candidates) {
|
|
75
|
+
let placed = false;
|
|
76
|
+
for (const group of groups) {
|
|
77
|
+
if (this.#similar(candidate.content, group[0].content)) {
|
|
78
|
+
group.push(candidate);
|
|
79
|
+
placed = true;
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (!placed) groups.push([candidate]);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Pick largest group
|
|
87
|
+
groups.sort((a, b) => b.length - a.length);
|
|
88
|
+
const bestGroup = groups[0];
|
|
89
|
+
const agreement = bestGroup.length / candidates.length;
|
|
90
|
+
|
|
91
|
+
// Winner = first in best group
|
|
92
|
+
return { winner: bestGroup[0], agreement };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
#similar(a: string, b: string): boolean {
|
|
96
|
+
if (this.#compareMode === "exact") return a.trim() === b.trim();
|
|
97
|
+
|
|
98
|
+
// Simple semantic: Jaccard similarity on words
|
|
99
|
+
const wordsA = new Set(a.toLowerCase().split(/\W+/).filter(Boolean));
|
|
100
|
+
const wordsB = new Set(b.toLowerCase().split(/\W+/).filter(Boolean));
|
|
101
|
+
const intersection = [...wordsA].filter(w => wordsB.has(w)).length;
|
|
102
|
+
const union = wordsA.size + wordsB.size - intersection;
|
|
103
|
+
return union > 0 ? intersection / union >= 0.7 : false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Critic Strategy — SNS-MyAgent Phase 5.3c
|
|
3
|
+
*
|
|
4
|
+
* Generator agent produces response → Critic agent reviews → Iterate up to max_rounds.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { AgentResponse, EnsembleResult, EnsembleStrategy } from "./types.js";
|
|
8
|
+
import { aggregateTokens } from "./types.js";
|
|
9
|
+
|
|
10
|
+
export interface CriticOptions {
|
|
11
|
+
/** Max iteration rounds (default: 2) */
|
|
12
|
+
maxRounds?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class CriticStrategy implements EnsembleStrategy {
|
|
16
|
+
readonly name = "critic";
|
|
17
|
+
readonly #maxRounds: number;
|
|
18
|
+
|
|
19
|
+
constructor(options: CriticOptions = {}) {
|
|
20
|
+
this.#maxRounds = options.maxRounds ?? 2;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async execute(
|
|
24
|
+
prompt: string,
|
|
25
|
+
agents: string[],
|
|
26
|
+
executeAgent: (role: string, prompt: string) => Promise<AgentResponse>,
|
|
27
|
+
): Promise<EnsembleResult> {
|
|
28
|
+
if (agents.length < 2) {
|
|
29
|
+
throw new Error("Critic strategy requires at least 2 agents: [generator, critic]");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const [generatorRole, criticRole] = agents;
|
|
33
|
+
const startTime = Date.now();
|
|
34
|
+
let currentPrompt = prompt;
|
|
35
|
+
const allResponses: AgentResponse[] = [];
|
|
36
|
+
let rounds = 0;
|
|
37
|
+
|
|
38
|
+
for (let round = 1; round <= this.#maxRounds; round++) {
|
|
39
|
+
rounds = round;
|
|
40
|
+
|
|
41
|
+
// Generator produces response
|
|
42
|
+
const genResponse = await executeAgent(generatorRole, currentPrompt);
|
|
43
|
+
allResponses.push(genResponse);
|
|
44
|
+
|
|
45
|
+
if (round === this.#maxRounds) {
|
|
46
|
+
// Last round: return generator's final response
|
|
47
|
+
return {
|
|
48
|
+
final: genResponse.content,
|
|
49
|
+
responses: allResponses,
|
|
50
|
+
strategy: this.name,
|
|
51
|
+
rounds,
|
|
52
|
+
totalTimeMs: Date.now() - startTime,
|
|
53
|
+
totalTokens: aggregateTokens(allResponses),
|
|
54
|
+
winner: generatorRole,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Critic reviews generator's response
|
|
59
|
+
const criticPrompt = this.#buildCriticPrompt(prompt, genResponse.content);
|
|
60
|
+
const criticResponse = await executeAgent(criticRole, criticPrompt);
|
|
61
|
+
allResponses.push(criticResponse);
|
|
62
|
+
|
|
63
|
+
// Check if critic is satisfied (score >= 0.7 or explicit "approved")
|
|
64
|
+
const satisfied = this.#isSatisfied(criticResponse);
|
|
65
|
+
if (satisfied) {
|
|
66
|
+
return {
|
|
67
|
+
final: genResponse.content,
|
|
68
|
+
responses: allResponses,
|
|
69
|
+
strategy: this.name,
|
|
70
|
+
rounds,
|
|
71
|
+
totalTimeMs: Date.now() - startTime,
|
|
72
|
+
totalTokens: aggregateTokens(allResponses),
|
|
73
|
+
winner: generatorRole,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Next iteration: improve based on critic feedback
|
|
78
|
+
currentPrompt = this.#buildImprovementPrompt(prompt, genResponse.content, criticResponse.content);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Max rounds reached, return last generator response
|
|
82
|
+
const finalGenResponse = allResponses[allResponses.length - 2]!;
|
|
83
|
+
return {
|
|
84
|
+
final: finalGenResponse.content,
|
|
85
|
+
responses: allResponses,
|
|
86
|
+
strategy: this.name,
|
|
87
|
+
rounds,
|
|
88
|
+
totalTimeMs: Date.now() - startTime,
|
|
89
|
+
totalTokens: aggregateTokens(allResponses),
|
|
90
|
+
winner: generatorRole,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
#buildCriticPrompt(originalPrompt: string, response: string): string {
|
|
95
|
+
return `Original task: ${originalPrompt}
|
|
96
|
+
|
|
97
|
+
Candidate response:
|
|
98
|
+
${response}
|
|
99
|
+
|
|
100
|
+
Critique this response. Provide:
|
|
101
|
+
1. Score 0-1 (1 = excellent, 0 = poor)
|
|
102
|
+
2. Specific issues/weaknesses
|
|
103
|
+
3. Suggested improvements
|
|
104
|
+
4. Verdict: "APPROVED" if score >= 0.7, otherwise "NEEDS IMPROVEMENT"
|
|
105
|
+
|
|
106
|
+
Format:
|
|
107
|
+
SCORE: <number>
|
|
108
|
+
ISSUES: <list>
|
|
109
|
+
IMPROVEMENTS: <list>
|
|
110
|
+
VERDICT: <APPROVED|NEEDS IMPROVEMENT>`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
#isSatisfied(criticResponse: AgentResponse): boolean {
|
|
114
|
+
const content = criticResponse.content.toUpperCase();
|
|
115
|
+
if (content.includes("APPROVED")) return true;
|
|
116
|
+
if (criticResponse.score !== undefined && criticResponse.score >= 0.7) return true;
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
#buildImprovementPrompt(originalPrompt: string, lastResponse: string, criticFeedback: string): string {
|
|
121
|
+
return `Original task: ${originalPrompt}
|
|
122
|
+
|
|
123
|
+
Previous attempt:
|
|
124
|
+
${lastResponse}
|
|
125
|
+
|
|
126
|
+
Critic feedback:
|
|
127
|
+
${criticFeedback}
|
|
128
|
+
|
|
129
|
+
Produce an IMPROVED response addressing all issues raised. Do not repeat the same mistakes.`;
|
|
130
|
+
}
|
|
131
|
+
}
|