confused-ai-core 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/FEATURES.md +169 -0
- package/package.json +119 -0
- package/src/agent.ts +187 -0
- package/src/agentic/index.ts +87 -0
- package/src/agentic/runner.ts +386 -0
- package/src/agentic/types.ts +91 -0
- package/src/artifacts/artifact.ts +417 -0
- package/src/artifacts/index.ts +42 -0
- package/src/artifacts/media.ts +304 -0
- package/src/cli/index.ts +122 -0
- package/src/core/base-agent.ts +151 -0
- package/src/core/context-builder.ts +106 -0
- package/src/core/index.ts +8 -0
- package/src/core/schemas.ts +17 -0
- package/src/core/types.ts +158 -0
- package/src/create-agent.ts +309 -0
- package/src/debug-logger.ts +188 -0
- package/src/dx/agent.ts +88 -0
- package/src/dx/define-agent.ts +183 -0
- package/src/dx/dev-logger.ts +57 -0
- package/src/dx/index.ts +11 -0
- package/src/errors.ts +175 -0
- package/src/execution/engine.ts +522 -0
- package/src/execution/graph-builder.ts +362 -0
- package/src/execution/index.ts +8 -0
- package/src/execution/types.ts +257 -0
- package/src/execution/worker-pool.ts +308 -0
- package/src/extensions/index.ts +123 -0
- package/src/guardrails/allowlist.ts +155 -0
- package/src/guardrails/index.ts +17 -0
- package/src/guardrails/types.ts +159 -0
- package/src/guardrails/validator.ts +265 -0
- package/src/index.ts +74 -0
- package/src/knowledge/index.ts +5 -0
- package/src/knowledge/types.ts +52 -0
- package/src/learning/in-memory-store.ts +72 -0
- package/src/learning/index.ts +6 -0
- package/src/learning/types.ts +42 -0
- package/src/llm/cache.ts +300 -0
- package/src/llm/index.ts +22 -0
- package/src/llm/model-resolver.ts +81 -0
- package/src/llm/openai-provider.ts +313 -0
- package/src/llm/openrouter-provider.ts +29 -0
- package/src/llm/types.ts +131 -0
- package/src/memory/in-memory-store.ts +255 -0
- package/src/memory/index.ts +7 -0
- package/src/memory/types.ts +193 -0
- package/src/memory/vector-store.ts +251 -0
- package/src/observability/console-logger.ts +123 -0
- package/src/observability/index.ts +12 -0
- package/src/observability/metrics.ts +85 -0
- package/src/observability/otlp-exporter.ts +417 -0
- package/src/observability/tracer.ts +105 -0
- package/src/observability/types.ts +341 -0
- package/src/orchestration/agent-adapter.ts +33 -0
- package/src/orchestration/index.ts +34 -0
- package/src/orchestration/load-balancer.ts +151 -0
- package/src/orchestration/mcp-types.ts +59 -0
- package/src/orchestration/message-bus.ts +192 -0
- package/src/orchestration/orchestrator.ts +349 -0
- package/src/orchestration/pipeline.ts +66 -0
- package/src/orchestration/supervisor.ts +107 -0
- package/src/orchestration/swarm.ts +1099 -0
- package/src/orchestration/toolkit.ts +47 -0
- package/src/orchestration/types.ts +339 -0
- package/src/planner/classical-planner.ts +383 -0
- package/src/planner/index.ts +8 -0
- package/src/planner/llm-planner.ts +353 -0
- package/src/planner/types.ts +227 -0
- package/src/planner/validator.ts +297 -0
- package/src/production/circuit-breaker.ts +290 -0
- package/src/production/graceful-shutdown.ts +251 -0
- package/src/production/health.ts +333 -0
- package/src/production/index.ts +57 -0
- package/src/production/latency-eval.ts +62 -0
- package/src/production/rate-limiter.ts +287 -0
- package/src/production/resumable-stream.ts +289 -0
- package/src/production/types.ts +81 -0
- package/src/sdk/index.ts +374 -0
- package/src/session/db-driver.ts +50 -0
- package/src/session/in-memory-store.ts +235 -0
- package/src/session/index.ts +12 -0
- package/src/session/sql-store.ts +315 -0
- package/src/session/sqlite-store.ts +61 -0
- package/src/session/types.ts +153 -0
- package/src/tools/base-tool.ts +223 -0
- package/src/tools/browser-tool.ts +123 -0
- package/src/tools/calculator-tool.ts +265 -0
- package/src/tools/file-tools.ts +394 -0
- package/src/tools/github-tool.ts +432 -0
- package/src/tools/hackernews-tool.ts +187 -0
- package/src/tools/http-tool.ts +118 -0
- package/src/tools/index.ts +99 -0
- package/src/tools/jira-tool.ts +373 -0
- package/src/tools/notion-tool.ts +322 -0
- package/src/tools/openai-tool.ts +236 -0
- package/src/tools/registry.ts +131 -0
- package/src/tools/serpapi-tool.ts +234 -0
- package/src/tools/shell-tool.ts +118 -0
- package/src/tools/slack-tool.ts +327 -0
- package/src/tools/telegram-tool.ts +127 -0
- package/src/tools/types.ts +229 -0
- package/src/tools/websearch-tool.ts +335 -0
- package/src/tools/wikipedia-tool.ts +177 -0
- package/src/tools/yfinance-tool.ts +33 -0
- package/src/voice/index.ts +17 -0
- package/src/voice/voice-provider.ts +228 -0
- package/tests/artifact.test.ts +241 -0
- package/tests/circuit-breaker.test.ts +171 -0
- package/tests/health.test.ts +192 -0
- package/tests/llm-cache.test.ts +186 -0
- package/tests/rate-limiter.test.ts +161 -0
- package/tsconfig.json +29 -0
- package/vitest.config.ts +47 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan validator implementation
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
ValidationResult,
|
|
7
|
+
ValidationError,
|
|
8
|
+
Plan,
|
|
9
|
+
Task,
|
|
10
|
+
TaskStatus,
|
|
11
|
+
} from './types.js';
|
|
12
|
+
import type { EntityId } from '../core/types.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Plan validator with comprehensive checks
|
|
16
|
+
*/
|
|
17
|
+
export class PlanValidator {
|
|
18
|
+
private customRules: ValidationRule[] = [];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Validate a plan comprehensively
|
|
22
|
+
*/
|
|
23
|
+
validate(plan: Plan): ValidationResult {
|
|
24
|
+
const errors: ValidationError[] = [];
|
|
25
|
+
|
|
26
|
+
// Check for empty plan
|
|
27
|
+
if (plan.tasks.length === 0) {
|
|
28
|
+
errors.push({
|
|
29
|
+
message: 'Plan contains no tasks',
|
|
30
|
+
severity: 'error',
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Check for duplicate task IDs
|
|
35
|
+
errors.push(...this.checkDuplicateIds(plan.tasks));
|
|
36
|
+
|
|
37
|
+
// Check for circular dependencies
|
|
38
|
+
errors.push(...this.checkCircularDependencies(plan.tasks));
|
|
39
|
+
|
|
40
|
+
// Check for orphaned tasks
|
|
41
|
+
errors.push(...this.checkOrphanedTasks(plan.tasks));
|
|
42
|
+
|
|
43
|
+
// Check for missing dependencies
|
|
44
|
+
errors.push(...this.checkMissingDependencies(plan.tasks));
|
|
45
|
+
|
|
46
|
+
// Check task validity
|
|
47
|
+
for (const task of plan.tasks) {
|
|
48
|
+
errors.push(...this.validateTask(task));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Apply custom rules
|
|
52
|
+
for (const rule of this.customRules) {
|
|
53
|
+
const ruleErrors = rule(plan);
|
|
54
|
+
errors.push(...ruleErrors);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
valid: errors.filter(e => e.severity === 'error').length === 0,
|
|
59
|
+
errors,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Add a custom validation rule
|
|
65
|
+
*/
|
|
66
|
+
addRule(rule: ValidationRule): void {
|
|
67
|
+
this.customRules.push(rule);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Remove a custom validation rule
|
|
72
|
+
*/
|
|
73
|
+
removeRule(rule: ValidationRule): void {
|
|
74
|
+
const index = this.customRules.indexOf(rule);
|
|
75
|
+
if (index > -1) {
|
|
76
|
+
this.customRules.splice(index, 1);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Check for duplicate task IDs
|
|
82
|
+
*/
|
|
83
|
+
private checkDuplicateIds(tasks: Task[]): ValidationError[] {
|
|
84
|
+
const errors: ValidationError[] = [];
|
|
85
|
+
const seenIds = new Set<EntityId>();
|
|
86
|
+
|
|
87
|
+
for (const task of tasks) {
|
|
88
|
+
if (seenIds.has(task.id)) {
|
|
89
|
+
errors.push({
|
|
90
|
+
taskId: task.id,
|
|
91
|
+
message: `Duplicate task ID: ${task.id}`,
|
|
92
|
+
severity: 'error',
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
seenIds.add(task.id);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return errors;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Check for circular dependencies using DFS
|
|
103
|
+
*/
|
|
104
|
+
private checkCircularDependencies(tasks: Task[]): ValidationError[] {
|
|
105
|
+
const errors: ValidationError[] = [];
|
|
106
|
+
const visited = new Set<EntityId>();
|
|
107
|
+
const recursionStack = new Set<EntityId>();
|
|
108
|
+
|
|
109
|
+
const hasCycle = (taskId: EntityId, path: EntityId[], taskMap: Map<EntityId, Task>): boolean => {
|
|
110
|
+
visited.add(taskId);
|
|
111
|
+
recursionStack.add(taskId);
|
|
112
|
+
|
|
113
|
+
const task = taskMap.get(taskId);
|
|
114
|
+
if (task) {
|
|
115
|
+
for (const depId of task.dependencies) {
|
|
116
|
+
if (!visited.has(depId)) {
|
|
117
|
+
if (hasCycle(depId, [...path, depId], taskMap)) {
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
} else if (recursionStack.has(depId)) {
|
|
121
|
+
// Found cycle
|
|
122
|
+
const cycleStart = path.indexOf(depId);
|
|
123
|
+
const cycle = path.slice(cycleStart).concat([taskId]);
|
|
124
|
+
errors.push({
|
|
125
|
+
taskId,
|
|
126
|
+
message: `Circular dependency detected: ${cycle.join(' -> ')}`,
|
|
127
|
+
severity: 'error',
|
|
128
|
+
});
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
recursionStack.delete(taskId);
|
|
135
|
+
return false;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
for (const task of tasks) {
|
|
139
|
+
if (!visited.has(task.id)) {
|
|
140
|
+
// Create task map for cycle detection
|
|
141
|
+
const taskMapForCycle = new Map(tasks.map(t => [t.id, t]));
|
|
142
|
+
hasCycle(task.id, [task.id], taskMapForCycle);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return errors;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Check for orphaned tasks (tasks not reachable from any root)
|
|
151
|
+
*/
|
|
152
|
+
private checkOrphanedTasks(tasks: Task[]): ValidationError[] {
|
|
153
|
+
const errors: ValidationError[] = [];
|
|
154
|
+
const reachable = new Set<EntityId>();
|
|
155
|
+
|
|
156
|
+
// Find all root tasks (no dependencies)
|
|
157
|
+
const roots = tasks.filter(t => t.dependencies.length === 0);
|
|
158
|
+
|
|
159
|
+
// BFS from roots to find all reachable tasks
|
|
160
|
+
const queue = [...roots];
|
|
161
|
+
while (queue.length > 0) {
|
|
162
|
+
const task = queue.shift()!;
|
|
163
|
+
reachable.add(task.id);
|
|
164
|
+
|
|
165
|
+
// Find tasks that depend on this task
|
|
166
|
+
for (const otherTask of tasks) {
|
|
167
|
+
if (otherTask.dependencies.includes(task.id) && !reachable.has(otherTask.id)) {
|
|
168
|
+
queue.push(otherTask);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Check for unreachable tasks
|
|
174
|
+
for (const task of tasks) {
|
|
175
|
+
if (!reachable.has(task.id)) {
|
|
176
|
+
errors.push({
|
|
177
|
+
taskId: task.id,
|
|
178
|
+
message: `Orphaned task: ${task.name} is not reachable from any root task`,
|
|
179
|
+
severity: 'warning',
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return errors;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Check for missing dependencies
|
|
189
|
+
*/
|
|
190
|
+
private checkMissingDependencies(tasks: Task[]): ValidationError[] {
|
|
191
|
+
const errors: ValidationError[] = [];
|
|
192
|
+
const taskIds = new Set(tasks.map(t => t.id));
|
|
193
|
+
|
|
194
|
+
for (const task of tasks) {
|
|
195
|
+
for (const depId of task.dependencies) {
|
|
196
|
+
if (!taskIds.has(depId)) {
|
|
197
|
+
errors.push({
|
|
198
|
+
taskId: task.id,
|
|
199
|
+
message: `Missing dependency: ${depId} does not exist in the plan`,
|
|
200
|
+
severity: 'error',
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return errors;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Validate a single task
|
|
211
|
+
*/
|
|
212
|
+
private validateTask(task: Task): ValidationError[] {
|
|
213
|
+
const errors: ValidationError[] = [];
|
|
214
|
+
|
|
215
|
+
// Check name
|
|
216
|
+
if (!task.name || task.name.trim().length === 0) {
|
|
217
|
+
errors.push({
|
|
218
|
+
taskId: task.id,
|
|
219
|
+
message: 'Task name is required',
|
|
220
|
+
severity: 'error',
|
|
221
|
+
});
|
|
222
|
+
} else if (task.name.length > 100) {
|
|
223
|
+
errors.push({
|
|
224
|
+
taskId: task.id,
|
|
225
|
+
message: 'Task name exceeds 100 characters',
|
|
226
|
+
severity: 'warning',
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Check description
|
|
231
|
+
if (!task.description || task.description.trim().length === 0) {
|
|
232
|
+
errors.push({
|
|
233
|
+
taskId: task.id,
|
|
234
|
+
message: 'Task description is required',
|
|
235
|
+
severity: 'warning',
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Check estimated duration
|
|
240
|
+
if (task.estimatedDurationMs !== undefined) {
|
|
241
|
+
if (task.estimatedDurationMs <= 0) {
|
|
242
|
+
errors.push({
|
|
243
|
+
taskId: task.id,
|
|
244
|
+
message: 'Estimated duration must be positive',
|
|
245
|
+
severity: 'error',
|
|
246
|
+
});
|
|
247
|
+
} else if (task.estimatedDurationMs > 86400000) { // 24 hours
|
|
248
|
+
errors.push({
|
|
249
|
+
taskId: task.id,
|
|
250
|
+
message: 'Estimated duration exceeds 24 hours, consider breaking into smaller tasks',
|
|
251
|
+
severity: 'warning',
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Check metadata
|
|
257
|
+
if (task.metadata.maxRetries !== undefined && task.metadata.maxRetries < 0) {
|
|
258
|
+
errors.push({
|
|
259
|
+
taskId: task.id,
|
|
260
|
+
message: 'Max retries must be non-negative',
|
|
261
|
+
severity: 'error',
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (task.metadata.timeoutMs !== undefined && task.metadata.timeoutMs <= 0) {
|
|
266
|
+
errors.push({
|
|
267
|
+
taskId: task.id,
|
|
268
|
+
message: 'Timeout must be positive',
|
|
269
|
+
severity: 'error',
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return errors;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Validate task execution result
|
|
278
|
+
*/
|
|
279
|
+
validateTaskResult(task: Task, result: { status: TaskStatus; error?: { message: string } }): ValidationError[] {
|
|
280
|
+
const errors: ValidationError[] = [];
|
|
281
|
+
|
|
282
|
+
if (result.status === TaskStatus.FAILED && !result.error) {
|
|
283
|
+
errors.push({
|
|
284
|
+
taskId: task.id,
|
|
285
|
+
message: 'Failed task result must include error information',
|
|
286
|
+
severity: 'error',
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return errors;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Custom validation rule type
|
|
296
|
+
*/
|
|
297
|
+
type ValidationRule = (plan: Plan) => ValidationError[];
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Circuit Breaker Pattern - Agno-style Production Resilience
|
|
3
|
+
*
|
|
4
|
+
* Prevents cascading failures by tracking error rates and temporarily
|
|
5
|
+
* blocking calls to failing services. Supports:
|
|
6
|
+
* - Configurable failure thresholds
|
|
7
|
+
* - Automatic recovery with half-open state
|
|
8
|
+
* - Metrics integration for observability
|
|
9
|
+
* - Event callbacks for monitoring
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { MetricsCollector } from '../observability/types.js';
|
|
13
|
+
|
|
14
|
+
/** Circuit breaker states */
|
|
15
|
+
export enum CircuitState {
|
|
16
|
+
/** Normal operation - requests pass through */
|
|
17
|
+
CLOSED = 'CLOSED',
|
|
18
|
+
/** Circuit tripped - requests are rejected immediately */
|
|
19
|
+
OPEN = 'OPEN',
|
|
20
|
+
/** Testing recovery - limited requests allowed */
|
|
21
|
+
HALF_OPEN = 'HALF_OPEN',
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Circuit breaker configuration */
|
|
25
|
+
export interface CircuitBreakerConfig {
|
|
26
|
+
/** Unique name for this circuit (for metrics/logging) */
|
|
27
|
+
readonly name: string;
|
|
28
|
+
/** Number of failures before opening circuit (default: 5) */
|
|
29
|
+
readonly failureThreshold?: number;
|
|
30
|
+
/** Number of successes in half-open before closing (default: 2) */
|
|
31
|
+
readonly successThreshold?: number;
|
|
32
|
+
/** Time in ms before attempting recovery (default: 30000) */
|
|
33
|
+
readonly resetTimeoutMs?: number;
|
|
34
|
+
/** Time window in ms for counting failures (default: 60000) */
|
|
35
|
+
readonly failureWindowMs?: number;
|
|
36
|
+
/** Optional metrics collector for observability */
|
|
37
|
+
readonly metrics?: MetricsCollector;
|
|
38
|
+
/** Callback when state changes */
|
|
39
|
+
readonly onStateChange?: (from: CircuitState, to: CircuitState) => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Circuit breaker execution result */
|
|
43
|
+
export interface CircuitBreakerResult<T> {
|
|
44
|
+
readonly success: boolean;
|
|
45
|
+
readonly value?: T;
|
|
46
|
+
readonly error?: Error;
|
|
47
|
+
readonly state: CircuitState;
|
|
48
|
+
readonly executionTimeMs: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Error thrown when circuit is open */
|
|
52
|
+
export class CircuitOpenError extends Error {
|
|
53
|
+
readonly circuitName: string;
|
|
54
|
+
readonly state: CircuitState;
|
|
55
|
+
readonly resetAt: Date;
|
|
56
|
+
|
|
57
|
+
constructor(name: string, resetAt: Date) {
|
|
58
|
+
super(`Circuit '${name}' is OPEN. Retry after ${resetAt.toISOString()}`);
|
|
59
|
+
this.name = 'CircuitOpenError';
|
|
60
|
+
this.circuitName = name;
|
|
61
|
+
this.state = CircuitState.OPEN;
|
|
62
|
+
this.resetAt = resetAt;
|
|
63
|
+
Object.setPrototypeOf(this, CircuitOpenError.prototype);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Failure record for sliding window */
|
|
68
|
+
interface FailureRecord {
|
|
69
|
+
readonly timestamp: number;
|
|
70
|
+
readonly error: Error;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Circuit Breaker implementation with sliding window failure tracking.
|
|
75
|
+
*
|
|
76
|
+
* @example
|
|
77
|
+
* const breaker = new CircuitBreaker({
|
|
78
|
+
* name: 'openai-api',
|
|
79
|
+
* failureThreshold: 5,
|
|
80
|
+
* resetTimeoutMs: 30000,
|
|
81
|
+
* });
|
|
82
|
+
*
|
|
83
|
+
* const result = await breaker.execute(() => openai.chat(...));
|
|
84
|
+
* if (result.success) {
|
|
85
|
+
* console.log(result.value);
|
|
86
|
+
* } else {
|
|
87
|
+
* console.error('Blocked or failed:', result.error);
|
|
88
|
+
* }
|
|
89
|
+
*/
|
|
90
|
+
export class CircuitBreaker {
|
|
91
|
+
private state: CircuitState = CircuitState.CLOSED;
|
|
92
|
+
private failures: FailureRecord[] = [];
|
|
93
|
+
private successCount = 0;
|
|
94
|
+
private lastFailureTime = 0;
|
|
95
|
+
private openedAt = 0;
|
|
96
|
+
|
|
97
|
+
private readonly config: Required<
|
|
98
|
+
Omit<CircuitBreakerConfig, 'metrics' | 'onStateChange'>
|
|
99
|
+
> & Pick<CircuitBreakerConfig, 'metrics' | 'onStateChange'>;
|
|
100
|
+
|
|
101
|
+
constructor(config: CircuitBreakerConfig) {
|
|
102
|
+
this.config = {
|
|
103
|
+
name: config.name,
|
|
104
|
+
failureThreshold: config.failureThreshold ?? 5,
|
|
105
|
+
successThreshold: config.successThreshold ?? 2,
|
|
106
|
+
resetTimeoutMs: config.resetTimeoutMs ?? 30_000,
|
|
107
|
+
failureWindowMs: config.failureWindowMs ?? 60_000,
|
|
108
|
+
metrics: config.metrics,
|
|
109
|
+
onStateChange: config.onStateChange,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Get current circuit state */
|
|
114
|
+
getState(): CircuitState {
|
|
115
|
+
return this.state;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Get circuit name */
|
|
119
|
+
getName(): string {
|
|
120
|
+
return this.config.name;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Check if circuit allows requests */
|
|
124
|
+
isAllowed(): boolean {
|
|
125
|
+
this.checkStateTransition();
|
|
126
|
+
return this.state !== CircuitState.OPEN;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Get time until circuit resets (if open) */
|
|
130
|
+
getResetTime(): Date | null {
|
|
131
|
+
if (this.state !== CircuitState.OPEN) return null;
|
|
132
|
+
return new Date(this.openedAt + this.config.resetTimeoutMs);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Execute a function through the circuit breaker.
|
|
137
|
+
* Tracks success/failure and manages state transitions.
|
|
138
|
+
*/
|
|
139
|
+
async execute<T>(fn: () => Promise<T>): Promise<CircuitBreakerResult<T>> {
|
|
140
|
+
const startTime = Date.now();
|
|
141
|
+
|
|
142
|
+
// Check if we should allow this request
|
|
143
|
+
this.checkStateTransition();
|
|
144
|
+
|
|
145
|
+
if (this.state === CircuitState.OPEN) {
|
|
146
|
+
const resetAt = this.getResetTime()!;
|
|
147
|
+
this.recordMetric('circuit_rejected', 1);
|
|
148
|
+
return {
|
|
149
|
+
success: false,
|
|
150
|
+
error: new CircuitOpenError(this.config.name, resetAt),
|
|
151
|
+
state: this.state,
|
|
152
|
+
executionTimeMs: Date.now() - startTime,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
const value = await fn();
|
|
158
|
+
this.recordSuccess();
|
|
159
|
+
return {
|
|
160
|
+
success: true,
|
|
161
|
+
value,
|
|
162
|
+
state: this.state,
|
|
163
|
+
executionTimeMs: Date.now() - startTime,
|
|
164
|
+
};
|
|
165
|
+
} catch (error) {
|
|
166
|
+
this.recordFailure(error as Error);
|
|
167
|
+
return {
|
|
168
|
+
success: false,
|
|
169
|
+
error: error as Error,
|
|
170
|
+
state: this.state,
|
|
171
|
+
executionTimeMs: Date.now() - startTime,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Force reset the circuit to closed state */
|
|
177
|
+
reset(): void {
|
|
178
|
+
this.transitionTo(CircuitState.CLOSED);
|
|
179
|
+
this.failures = [];
|
|
180
|
+
this.successCount = 0;
|
|
181
|
+
this.lastFailureTime = 0;
|
|
182
|
+
this.openedAt = 0;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Get current failure count within window */
|
|
186
|
+
getFailureCount(): number {
|
|
187
|
+
this.pruneOldFailures();
|
|
188
|
+
return this.failures.length;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Get circuit statistics */
|
|
192
|
+
getStats(): {
|
|
193
|
+
state: CircuitState;
|
|
194
|
+
failureCount: number;
|
|
195
|
+
successCount: number;
|
|
196
|
+
lastFailure: Date | null;
|
|
197
|
+
} {
|
|
198
|
+
return {
|
|
199
|
+
state: this.state,
|
|
200
|
+
failureCount: this.getFailureCount(),
|
|
201
|
+
successCount: this.successCount,
|
|
202
|
+
lastFailure: this.lastFailureTime > 0 ? new Date(this.lastFailureTime) : null,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// --- Private methods ---
|
|
207
|
+
|
|
208
|
+
private checkStateTransition(): void {
|
|
209
|
+
const now = Date.now();
|
|
210
|
+
|
|
211
|
+
if (this.state === CircuitState.OPEN) {
|
|
212
|
+
// Check if reset timeout has passed
|
|
213
|
+
if (now - this.openedAt >= this.config.resetTimeoutMs) {
|
|
214
|
+
this.transitionTo(CircuitState.HALF_OPEN);
|
|
215
|
+
this.successCount = 0;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private recordSuccess(): void {
|
|
221
|
+
this.recordMetric('circuit_success', 1);
|
|
222
|
+
|
|
223
|
+
if (this.state === CircuitState.HALF_OPEN) {
|
|
224
|
+
this.successCount++;
|
|
225
|
+
if (this.successCount >= this.config.successThreshold) {
|
|
226
|
+
this.transitionTo(CircuitState.CLOSED);
|
|
227
|
+
this.failures = [];
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private recordFailure(error: Error): void {
|
|
233
|
+
const now = Date.now();
|
|
234
|
+
this.lastFailureTime = now;
|
|
235
|
+
this.failures.push({ timestamp: now, error });
|
|
236
|
+
this.recordMetric('circuit_failure', 1);
|
|
237
|
+
|
|
238
|
+
this.pruneOldFailures();
|
|
239
|
+
|
|
240
|
+
if (this.state === CircuitState.HALF_OPEN) {
|
|
241
|
+
// Any failure in half-open immediately opens
|
|
242
|
+
this.transitionTo(CircuitState.OPEN);
|
|
243
|
+
this.openedAt = now;
|
|
244
|
+
} else if (this.state === CircuitState.CLOSED) {
|
|
245
|
+
if (this.failures.length >= this.config.failureThreshold) {
|
|
246
|
+
this.transitionTo(CircuitState.OPEN);
|
|
247
|
+
this.openedAt = now;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private pruneOldFailures(): void {
|
|
253
|
+
const cutoff = Date.now() - this.config.failureWindowMs;
|
|
254
|
+
this.failures = this.failures.filter(f => f.timestamp > cutoff);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private transitionTo(newState: CircuitState): void {
|
|
258
|
+
if (this.state === newState) return;
|
|
259
|
+
|
|
260
|
+
const oldState = this.state;
|
|
261
|
+
this.state = newState;
|
|
262
|
+
|
|
263
|
+
this.recordMetric('circuit_state_change', 1, { from: oldState, to: newState });
|
|
264
|
+
this.config.onStateChange?.(oldState, newState);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private recordMetric(name: string, value: number, labels: Record<string, string> = {}): void {
|
|
268
|
+
this.config.metrics?.counter(`${this.config.name}.${name}`, value, {
|
|
269
|
+
circuit: this.config.name,
|
|
270
|
+
...labels,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Create a circuit breaker with common defaults for LLM providers.
|
|
277
|
+
*/
|
|
278
|
+
export function createLLMCircuitBreaker(
|
|
279
|
+
name: string,
|
|
280
|
+
options?: Partial<CircuitBreakerConfig>
|
|
281
|
+
): CircuitBreaker {
|
|
282
|
+
return new CircuitBreaker({
|
|
283
|
+
name,
|
|
284
|
+
failureThreshold: 3,
|
|
285
|
+
successThreshold: 2,
|
|
286
|
+
resetTimeoutMs: 30_000,
|
|
287
|
+
failureWindowMs: 60_000,
|
|
288
|
+
...options,
|
|
289
|
+
});
|
|
290
|
+
}
|