bare-agent 0.2.0 → 0.2.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 CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  # bare-agent
15
15
 
16
- **Agent orchestration in ~800 lines. Zero required deps. MIT license.**
16
+ **Agent orchestration in ~1700 lines. Zero required deps. MIT license.**
17
17
 
18
18
  Everything between "call the LLM" and "ship the agent" — loop, plan, remember, schedule, checkpoint. Each works alone. All compose together.
19
19
 
@@ -47,6 +47,7 @@ Three layers. You use the first two. You bring the third.
47
47
  | **Planner** | Goal -> step DAG | Structured output prompt, LLM returns JSON dependency graph |
48
48
  | **State** | Task lifecycle tracking | `pending -> running -> done \| failed`, persisted to JSON file |
49
49
  | **Stream** | Event streaming | One JSON object per line to stdout, pipe-friendly, any-language |
50
+ | **Errors** | Typed error hierarchy | `BareAgentError` base, `ProviderError`, `ToolError`, `TimeoutError`, `CircuitOpenError` |
50
51
 
51
52
  ### Layer 2: EXECUTION — how the agent thinks, remembers, acts, and persist?
52
53
 
@@ -56,7 +57,9 @@ Three layers. You use the first two. You bring the third.
56
57
  | **Scheduler** | Time-triggered turns | Cron (`0 7 * * 1-5`), relative (`2h`, `30m`), persisted jobs |
57
58
  | **Memory** | Persist + search | SQLite FTS5 with BM25 (default), JSON file fallback (zero deps) |
58
59
  | **Checkpoint** | Human approval gate | You provide the transport — readline, Telegram, WebSocket |
59
- | **Retry** | Backoff on failure | Exponential/linear, retries on 429/5xx/network errors |
60
+ | **Retry** | Backoff on failure | Exponential/linear with jitter, retries on 429/5xx/network errors |
61
+ | **CircuitBreaker** | Fail-fast on repeated errors | Per-key threshold, auto half-open probe, `wrapProvider()` |
62
+ | **Fallback** | Multi-provider resilience | Tries providers in order, AggregateError if all fail |
60
63
 
61
64
  ### Layer 3: ACTUATION — you provide this
62
65
 
@@ -159,17 +162,42 @@ const loop = new Loop({
159
162
  await loop.runGoal('Book my Berlin trip for next Tuesday');
160
163
  ```
161
164
 
165
+ ### Resilient multi-provider — circuit breaker + fallback + jitter
166
+
167
+ ```javascript
168
+ const { Loop, Retry, CircuitBreaker } = require('bare-agent');
169
+ const { OpenAI, Anthropic, Fallback } = require('bare-agent/providers');
170
+
171
+ const cb = new CircuitBreaker({ threshold: 3, resetAfter: 30000 });
172
+
173
+ const provider = new Fallback([
174
+ cb.wrapProvider(new OpenAI({ apiKey: process.env.OPENAI_API_KEY }), 'openai'),
175
+ cb.wrapProvider(new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY }), 'anthropic'),
176
+ ]);
177
+
178
+ const loop = new Loop({
179
+ provider,
180
+ retry: new Retry({ maxAttempts: 3, jitter: 'full' }),
181
+ });
182
+
183
+ const result = await loop.run([
184
+ { role: 'user', content: 'Summarize today\'s news' }
185
+ ]);
186
+ ```
187
+
162
188
  ---
163
189
 
164
190
  ## LLM Providers
165
191
 
166
- Three built-in. All implement one method: `generate(messages, tools, options) -> { text, toolCalls, usage }`.
192
+ All implement one method: `generate(messages, tools, options) -> { text, toolCalls, usage }`.
167
193
 
168
194
  | Provider | Covers |
169
195
  |---|---|
170
196
  | **OpenAI** | OpenAI, OpenRouter, Together, Groq, vLLM, LM Studio — any OpenAI-compatible endpoint |
171
197
  | **Anthropic** | Claude models via native API |
172
198
  | **Ollama** | Local models, no API key needed |
199
+ | **CLIPipe** | Any CLI tool via stdin/stdout (claude, ollama run, etc.) |
200
+ | **Fallback** | Tries multiple providers in order — transparent to Loop |
173
201
  | **Bring your own** | Implement `generate()` — one method, full control |
174
202
 
175
203
  ## Storage
@@ -217,12 +245,19 @@ Same pattern works from Go, Rust, Java, Ruby — any language that can spawn a p
217
245
  required: 0
218
246
  optional: cron-parser (for cron expressions in scheduler)
219
247
  peer: better-sqlite3 (for SQLite memory store)
220
- total lines: ~820
248
+ total lines: ~1700
221
249
  ```
222
250
 
223
251
  ## Status
224
252
 
225
- Early development. Core components built and validated through POCs. See [project plan](docs/01-product/prd.md) for the full design.
253
+ **Production-validated.** bare-agent powers the SOAR2 pipeline in [Aurora](https://github.com/hamr0/aurora), replacing ~400 lines of hand-rolled agent orchestration with ~60 lines of bare-agent wiring. In production use, bare-agent eliminated:
254
+
255
+ - **Boilerplate** — Tool-calling loop, provider normalization, retry logic, and state tracking that every agent project reinvents. Aurora's SOAR2 pipeline dropped from custom loop + manual state management to `Loop + Planner + runPlan + StateMachine`.
256
+ - **Fragile glue code** — Manual wave execution, dependency resolution, and error propagation replaced by `runPlan` with built-in parallelism and failure cascading.
257
+ - **Provider lock-in** — Switching from OpenAI to Anthropic to CLIPipe required zero orchestration changes — just swap the provider constructor.
258
+ - **Debugging friction** — Structured `[ComponentName]` error prefixes and `Stream` events made failures traceable in minutes instead of hours.
259
+
260
+ See [project plan](docs/01-product/prd.md) for the full design. See [CHANGELOG.md](CHANGELOG.md) for release history.
226
261
 
227
262
  ## License
228
263
 
package/index.js CHANGED
@@ -9,6 +9,15 @@ const { Memory } = require('./src/memory');
9
9
  const { Stream } = require('./src/stream');
10
10
  const { Retry } = require('./src/retry');
11
11
  const { runPlan } = require('./src/run-plan');
12
+ const { CircuitBreaker } = require('./src/circuit-breaker');
13
+ const {
14
+ BareAgentError,
15
+ ProviderError,
16
+ ToolError,
17
+ TimeoutError,
18
+ ValidationError,
19
+ CircuitOpenError,
20
+ } = require('./src/errors');
12
21
 
13
22
  module.exports = {
14
23
  Loop,
@@ -20,4 +29,11 @@ module.exports = {
20
29
  Stream,
21
30
  Retry,
22
31
  runPlan,
32
+ CircuitBreaker,
33
+ BareAgentError,
34
+ ProviderError,
35
+ ToolError,
36
+ TimeoutError,
37
+ ValidationError,
38
+ CircuitOpenError,
23
39
  };
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "bare-agent",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "files": [
5
5
  "index.js",
6
6
  "src/",
7
7
  "bin/"
8
8
  ],
9
- "description": "Lightweight, composable agent orchestration. ~800 lines, 0 required deps.",
9
+ "description": "Lightweight, composable agent orchestration. ~1700 lines, 0 required deps.",
10
10
  "license": "MIT",
11
11
  "author": "hamr0",
12
12
  "repository": {
@@ -20,7 +20,8 @@
20
20
  "exports": {
21
21
  ".": "./index.js",
22
22
  "./providers": "./src/providers.js",
23
- "./stores": "./src/stores.js"
23
+ "./stores": "./src/stores.js",
24
+ "./transports": "./src/transports.js"
24
25
  },
25
26
  "engines": {
26
27
  "node": ">=18"
@@ -0,0 +1,112 @@
1
+ 'use strict';
2
+
3
+ const { CircuitOpenError } = require('./errors');
4
+
5
+ class CircuitBreaker {
6
+ /**
7
+ * @param {object} [options={}]
8
+ * @param {number} [options.threshold=5] - Failures before opening.
9
+ * @param {number} [options.resetAfter=60000] - Ms before half-open probe.
10
+ * @param {function} [options.onStateChange] - Callback(key, from, to).
11
+ */
12
+ constructor(options = {}) {
13
+ this.threshold = options.threshold || 5;
14
+ this.resetAfter = options.resetAfter || 60000;
15
+ this.onStateChange = options.onStateChange || null;
16
+ this._keys = new Map();
17
+ }
18
+
19
+ _getEntry(key) {
20
+ if (!this._keys.has(key)) {
21
+ this._keys.set(key, { state: 'closed', failures: 0, openedAt: 0, generation: 0 });
22
+ }
23
+ return this._keys.get(key);
24
+ }
25
+
26
+ _setState(entry, key, newState) {
27
+ const from = entry.state;
28
+ if (from === newState) return;
29
+ entry.state = newState;
30
+ this.onStateChange?.(key, from, newState);
31
+ }
32
+
33
+ /**
34
+ * Execute fn through the circuit breaker.
35
+ * @param {function} fn - Async function to call.
36
+ * @param {string} [key='default'] - Circuit key for per-key isolation.
37
+ * @returns {Promise<*>}
38
+ * @throws {CircuitOpenError} When circuit is open.
39
+ */
40
+ async call(fn, key = 'default') {
41
+ const entry = this._getEntry(key);
42
+
43
+ if (entry.state === 'open') {
44
+ if (Date.now() - entry.openedAt >= this.resetAfter) {
45
+ this._setState(entry, key, 'half-open');
46
+ entry.generation++;
47
+ } else {
48
+ throw new CircuitOpenError(`Circuit "${key}" is open`);
49
+ }
50
+ }
51
+
52
+ const gen = entry.generation;
53
+
54
+ try {
55
+ const result = await fn();
56
+ // Only close if still same generation (prevents stale half-open races)
57
+ if (entry.generation === gen) {
58
+ entry.failures = 0;
59
+ if (entry.state === 'half-open') {
60
+ this._setState(entry, key, 'closed');
61
+ }
62
+ }
63
+ return result;
64
+ } catch (err) {
65
+ if (entry.generation === gen) {
66
+ entry.failures++;
67
+ if (entry.state === 'half-open') {
68
+ entry.openedAt = Date.now();
69
+ this._setState(entry, key, 'open');
70
+ } else if (entry.failures >= this.threshold) {
71
+ entry.openedAt = Date.now();
72
+ this._setState(entry, key, 'open');
73
+ }
74
+ }
75
+ throw err;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Get current state for a key.
81
+ * @param {string} [key='default']
82
+ * @returns {'closed'|'open'|'half-open'}
83
+ */
84
+ getState(key = 'default') {
85
+ return this._getEntry(key).state;
86
+ }
87
+
88
+ /**
89
+ * Force reset a key to closed.
90
+ * @param {string} [key='default']
91
+ */
92
+ reset(key = 'default') {
93
+ const entry = this._getEntry(key);
94
+ entry.failures = 0;
95
+ entry.openedAt = 0;
96
+ this._setState(entry, key, 'closed');
97
+ }
98
+
99
+ /**
100
+ * Wrap a provider so generate() goes through the circuit breaker.
101
+ * @param {object} provider - Provider with generate().
102
+ * @param {string} [key] - Circuit key.
103
+ * @returns {object} Wrapped provider with generate().
104
+ */
105
+ wrapProvider(provider, key) {
106
+ return {
107
+ generate: (...args) => this.call(() => provider.generate(...args), key),
108
+ };
109
+ }
110
+ }
111
+
112
+ module.exports = { CircuitBreaker };
package/src/errors.js ADDED
@@ -0,0 +1,53 @@
1
+ 'use strict';
2
+
3
+ class BareAgentError extends Error {
4
+ constructor(message, { code, retryable = false, context = {} } = {}) {
5
+ super(message);
6
+ this.name = this.constructor.name;
7
+ this.code = code || undefined;
8
+ this.retryable = retryable;
9
+ this.context = context;
10
+ }
11
+ }
12
+
13
+ class ProviderError extends BareAgentError {
14
+ constructor(message, { status, body, context = {} } = {}) {
15
+ const retryable = status === 429 || (status >= 500 && status <= 504);
16
+ super(message, { code: 'PROVIDER_ERROR', retryable, context });
17
+ this.status = status;
18
+ this.body = body;
19
+ }
20
+ }
21
+
22
+ class ToolError extends BareAgentError {
23
+ constructor(message, opts = {}) {
24
+ super(message, { code: 'TOOL_ERROR', retryable: false, ...opts });
25
+ }
26
+ }
27
+
28
+ class TimeoutError extends BareAgentError {
29
+ constructor(message, opts = {}) {
30
+ super(message || 'Operation timed out', { code: 'ETIMEDOUT', retryable: true, ...opts });
31
+ }
32
+ }
33
+
34
+ class ValidationError extends BareAgentError {
35
+ constructor(message, opts = {}) {
36
+ super(message, { code: 'VALIDATION_ERROR', retryable: false, ...opts });
37
+ }
38
+ }
39
+
40
+ class CircuitOpenError extends BareAgentError {
41
+ constructor(message, opts = {}) {
42
+ super(message || 'Circuit breaker is open', { code: 'CIRCUIT_OPEN', retryable: true, ...opts });
43
+ }
44
+ }
45
+
46
+ module.exports = {
47
+ BareAgentError,
48
+ ProviderError,
49
+ ToolError,
50
+ TimeoutError,
51
+ ValidationError,
52
+ CircuitOpenError,
53
+ };
package/src/loop.js CHANGED
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const { ToolError } = require('./errors');
4
+
3
5
  class Loop {
4
6
  /**
5
7
  * @param {object} options
@@ -135,7 +137,8 @@ class Loop {
135
137
  msgs.push({ role: 'tool', tool_call_id: tc.id, content });
136
138
  this.stream?.emit({ type: 'loop:tool_result', data: { tool: tc.name, result: content } });
137
139
  } catch (err) {
138
- const errMsg = `[Loop] Tool error: ${err.message}`;
140
+ const toolErr = err instanceof ToolError ? err : new ToolError(err.message, { context: { tool: tc.name } });
141
+ const errMsg = `[Loop] Tool error: ${toolErr.message}`;
139
142
  msgs.push({ role: 'tool', tool_call_id: tc.id, content: errMsg });
140
143
  this.stream?.emit({ type: 'loop:tool_result', data: { tool: tc.name, error: errMsg } });
141
144
  }
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const https = require('https');
4
+ const { ProviderError } = require('./errors');
4
5
 
5
6
  class AnthropicProvider {
6
7
  /**
@@ -123,10 +124,10 @@ class AnthropicProvider {
123
124
  try {
124
125
  const parsed = JSON.parse(chunks);
125
126
  if (res.statusCode >= 400) {
126
- const err = new Error(`[AnthropicProvider] ${parsed.error?.message || `HTTP ${res.statusCode}`}`);
127
- err.status = res.statusCode;
128
- err.body = parsed;
129
- return reject(err);
127
+ return reject(new ProviderError(
128
+ `[AnthropicProvider] ${parsed.error?.message || `HTTP ${res.statusCode}`}`,
129
+ { status: res.statusCode, body: parsed }
130
+ ));
130
131
  }
131
132
  resolve(parsed);
132
133
  } catch (e) {
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const { spawn } = require('child_process');
4
+ const { ProviderError } = require('./errors');
4
5
 
5
6
  class CLIPipeProvider {
6
7
  /**
@@ -11,6 +12,7 @@ class CLIPipeProvider {
11
12
  * @param {string} [options.cwd] - Working directory for the child process.
12
13
  * @param {object} [options.env] - Environment variables for the child process.
13
14
  * @param {number} [options.timeout=30000] - Timeout in milliseconds.
15
+ * @param {string} [options.systemPromptFlag] - CLI flag for system prompt (e.g. '--system'). When set, system messages are extracted and passed via this flag instead of stdin.
14
16
  * @throws {Error} `[CLIPipeProvider] requires command` — when options.command is missing.
15
17
  */
16
18
  constructor(options = {}) {
@@ -20,6 +22,7 @@ class CLIPipeProvider {
20
22
  this.cwd = options.cwd || undefined;
21
23
  this.env = options.env || undefined;
22
24
  this.timeout = options.timeout ?? 30000;
25
+ this.systemPromptFlag = options.systemPromptFlag || null;
23
26
  }
24
27
 
25
28
  /**
@@ -34,8 +37,20 @@ class CLIPipeProvider {
34
37
  * @throws {Error} `[CLIPipeProvider] process produced no output` — when stdout is empty.
35
38
  */
36
39
  async generate(messages, tools = [], options = {}) {
37
- const prompt = this._formatPrompt(messages);
38
- const text = await this._spawn(prompt);
40
+ let extraArgs = [];
41
+ let promptMessages = messages;
42
+
43
+ if (this.systemPromptFlag) {
44
+ const systemMessages = messages.filter(m => m.role === 'system');
45
+ if (systemMessages.length > 0) {
46
+ const systemContent = systemMessages.map(m => m.content).join('\n\n');
47
+ extraArgs = [this.systemPromptFlag, systemContent];
48
+ promptMessages = messages.filter(m => m.role !== 'system');
49
+ }
50
+ }
51
+
52
+ const prompt = this._formatPrompt(promptMessages);
53
+ const text = await this._spawn(prompt, extraArgs);
39
54
  return {
40
55
  text,
41
56
  toolCalls: [],
@@ -58,11 +73,12 @@ class CLIPipeProvider {
58
73
  /**
59
74
  * Spawn the CLI process, pipe prompt to stdin, collect stdout.
60
75
  * @param {string} prompt
76
+ * @param {string[]} [extraArgs=[]] - Additional args prepended to this.args.
61
77
  * @returns {Promise<string>}
62
78
  */
63
- _spawn(prompt) {
79
+ _spawn(prompt, extraArgs = []) {
64
80
  return new Promise((resolve, reject) => {
65
- const child = spawn(this.command, this.args, {
81
+ const child = spawn(this.command, [...this.args, ...extraArgs], {
66
82
  cwd: this.cwd,
67
83
  env: this.env,
68
84
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -76,17 +92,17 @@ class CLIPipeProvider {
76
92
  child.stderr.on('data', d => { stderr += d; });
77
93
 
78
94
  child.on('error', err => {
79
- reject(new Error(`[CLIPipeProvider] failed to spawn "${this.command}": ${err.message}`));
95
+ reject(new ProviderError(`[CLIPipeProvider] failed to spawn "${this.command}": ${err.message}`, { status: 0 }));
80
96
  });
81
97
 
82
98
  child.on('close', code => {
83
99
  if (killed) return; // timeout already rejected
84
100
  if (code !== 0) {
85
- return reject(new Error(`[CLIPipeProvider] process exited with code ${code}: ${stderr.trim()}`));
101
+ return reject(new ProviderError(`[CLIPipeProvider] process exited with code ${code}: ${stderr.trim()}`, { status: code }));
86
102
  }
87
103
  const text = stdout.trim();
88
104
  if (!text) {
89
- return reject(new Error('[CLIPipeProvider] process produced no output'));
105
+ return reject(new ProviderError('[CLIPipeProvider] process produced no output', { status: 0 }));
90
106
  }
91
107
  resolve(text);
92
108
  });
@@ -98,7 +114,7 @@ class CLIPipeProvider {
98
114
  setTimeout(() => {
99
115
  try { child.kill('SIGKILL'); } catch (_) {}
100
116
  }, 1000);
101
- reject(new Error(`[CLIPipeProvider] timed out after ${this.timeout}ms`));
117
+ reject(new ProviderError(`[CLIPipeProvider] timed out after ${this.timeout}ms`, { status: 0 }));
102
118
  }, this.timeout);
103
119
 
104
120
  child.on('close', () => clearTimeout(timer));
@@ -0,0 +1,48 @@
1
+ 'use strict';
2
+
3
+ class FallbackProvider {
4
+ /**
5
+ * Provider that tries multiple providers in order.
6
+ * @param {Array<object>} providers - Ordered list of providers with generate().
7
+ * @param {object} [options={}]
8
+ * @param {function} [options.shouldFallback] - (error, index) => boolean. Return false to stop.
9
+ * @param {function} [options.onFallback] - (error, fromIndex, toIndex) callback.
10
+ * @throws {Error} `[FallbackProvider] requires at least one provider` — when providers is empty.
11
+ */
12
+ constructor(providers, options = {}) {
13
+ if (!Array.isArray(providers) || providers.length === 0) {
14
+ throw new Error('[FallbackProvider] requires at least one provider');
15
+ }
16
+ this.providers = providers;
17
+ this.shouldFallback = options.shouldFallback || (() => true);
18
+ this.onFallback = options.onFallback || null;
19
+ }
20
+
21
+ /**
22
+ * Generate using first available provider.
23
+ * @param {Array<object>} messages
24
+ * @param {Array<object>} [tools=[]]
25
+ * @param {object} [options={}]
26
+ * @returns {Promise<{text: string, toolCalls: Array, usage: object}>}
27
+ * @throws {AggregateError} When all providers fail.
28
+ */
29
+ async generate(messages, tools = [], options = {}) {
30
+ const errors = [];
31
+
32
+ for (let i = 0; i < this.providers.length; i++) {
33
+ try {
34
+ return await this.providers[i].generate(messages, tools, options);
35
+ } catch (err) {
36
+ errors.push(err);
37
+ if (i < this.providers.length - 1) {
38
+ if (!this.shouldFallback(err, i)) throw err;
39
+ this.onFallback?.(err, i, i + 1);
40
+ }
41
+ }
42
+ }
43
+
44
+ throw new AggregateError(errors, '[FallbackProvider] all providers failed');
45
+ }
46
+ }
47
+
48
+ module.exports = { FallbackProvider };
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const http = require('http');
4
+ const { ProviderError } = require('./errors');
4
5
 
5
6
  class OllamaProvider {
6
7
  constructor(options = {}) {
@@ -67,9 +68,10 @@ class OllamaProvider {
67
68
  try {
68
69
  const parsed = JSON.parse(chunks);
69
70
  if (res.statusCode >= 400) {
70
- const err = new Error(`[OllamaProvider] ${parsed.error || `HTTP ${res.statusCode}`}`);
71
- err.status = res.statusCode;
72
- return reject(err);
71
+ return reject(new ProviderError(
72
+ `[OllamaProvider] ${parsed.error || `HTTP ${res.statusCode}`}`,
73
+ { status: res.statusCode, body: parsed }
74
+ ));
73
75
  }
74
76
  resolve(parsed);
75
77
  } catch (e) {
@@ -2,6 +2,7 @@
2
2
 
3
3
  const https = require('https');
4
4
  const http = require('http');
5
+ const { ProviderError } = require('./errors');
5
6
 
6
7
  class OpenAIProvider {
7
8
  constructor(options = {}) {
@@ -70,10 +71,10 @@ class OpenAIProvider {
70
71
  try {
71
72
  const parsed = JSON.parse(chunks);
72
73
  if (res.statusCode >= 400) {
73
- const err = new Error(`[OpenAIProvider] ${parsed.error?.message || `HTTP ${res.statusCode}`}`);
74
- err.status = res.statusCode;
75
- err.body = parsed;
76
- return reject(err);
74
+ return reject(new ProviderError(
75
+ `[OpenAIProvider] ${parsed.error?.message || `HTTP ${res.statusCode}`}`,
76
+ { status: res.statusCode, body: parsed }
77
+ ));
77
78
  }
78
79
  resolve(parsed);
79
80
  } catch (e) {
package/src/providers.js CHANGED
@@ -4,10 +4,12 @@ const { OpenAIProvider } = require('./provider-openai');
4
4
  const { AnthropicProvider } = require('./provider-anthropic');
5
5
  const { OllamaProvider } = require('./provider-ollama');
6
6
  const { CLIPipeProvider } = require('./provider-clipipe');
7
+ const { FallbackProvider } = require('./provider-fallback');
7
8
 
8
9
  module.exports = {
9
10
  OpenAI: OpenAIProvider,
10
11
  Anthropic: AnthropicProvider,
11
12
  Ollama: OllamaProvider,
12
13
  CLIPipe: CLIPipeProvider,
14
+ Fallback: FallbackProvider,
13
15
  };
package/src/retry.js CHANGED
@@ -1,6 +1,10 @@
1
1
  'use strict';
2
2
 
3
+ const { TimeoutError } = require('./errors');
4
+
3
5
  const DEFAULT_RETRY_ON = (err) => {
6
+ if (err.retryable === true) return true;
7
+ if (err.retryable === false) return false;
4
8
  const status = err.status || err.statusCode;
5
9
  if (status === 429 || (status >= 500 && status <= 504)) return true;
6
10
  const code = err.code;
@@ -14,6 +18,7 @@ class Retry {
14
18
  this.backoff = options.backoff || 'exponential';
15
19
  this.timeout = options.timeout || 60000;
16
20
  this.retryOn = options.retryOn || DEFAULT_RETRY_ON;
21
+ this.jitter = options.jitter !== undefined ? options.jitter : false;
17
22
  }
18
23
 
19
24
  /**
@@ -21,7 +26,7 @@ class Retry {
21
26
  * @param {() => Promise<*>} fn - Async function to execute.
22
27
  * @param {object} [options={}] - Per-call overrides for maxAttempts, retryOn, timeout.
23
28
  * @returns {Promise<*>} The result of fn().
24
- * @throws {Error} `[Retry] Timeout` — when an individual attempt exceeds the timeout.
29
+ * @throws {TimeoutError} When an individual attempt exceeds the timeout.
25
30
  * @throws {Error} Rethrows the last error when maxAttempts is exhausted or error is not retryable.
26
31
  */
27
32
  async call(fn, options = {}) {
@@ -32,7 +37,7 @@ class Retry {
32
37
  for (let attempt = 1; attempt <= max; attempt++) {
33
38
  try {
34
39
  const result = await (timeout
35
- ? Promise.race([fn(), new Promise((_, rej) => setTimeout(() => rej(Object.assign(new Error('[Retry] Timeout'), { code: 'ETIMEDOUT' })), timeout))])
40
+ ? Promise.race([fn(), new Promise((_, rej) => setTimeout(() => rej(new TimeoutError('[Retry] Timeout')), timeout))])
36
41
  : fn());
37
42
  return result;
38
43
  } catch (err) {
@@ -44,9 +49,30 @@ class Retry {
44
49
  }
45
50
 
46
51
  _delay(attempt) {
47
- if (typeof this.backoff === 'number') return this.backoff;
48
- if (this.backoff === 'linear') return attempt * 1000;
49
- return Math.min(2 ** (attempt - 1) * 1000, 30000); // exponential, cap 30s
52
+ let base;
53
+ if (typeof this.backoff === 'number') {
54
+ base = this.backoff;
55
+ } else if (this.backoff === 'linear') {
56
+ base = attempt * 1000;
57
+ } else {
58
+ base = Math.min(2 ** (attempt - 1) * 1000, 30000); // exponential, cap 30s
59
+ }
60
+ return this._applyJitter(base);
61
+ }
62
+
63
+ _applyJitter(base) {
64
+ if (this.jitter === false || this.jitter === 0) return base;
65
+ if (this.jitter === 'full') {
66
+ return Math.floor(Math.random() * base);
67
+ }
68
+ if (this.jitter === 'equal') {
69
+ return Math.floor(base / 2 + Math.random() * (base / 2));
70
+ }
71
+ if (typeof this.jitter === 'number') {
72
+ const spread = base * this.jitter;
73
+ return Math.floor(base - spread + Math.random() * spread);
74
+ }
75
+ return base;
50
76
  }
51
77
  }
52
78
 
package/src/run-plan.js CHANGED
@@ -10,6 +10,7 @@
10
10
  * @param {function} [options.onStepStart] - Callback(step) fired when a step begins.
11
11
  * @param {function} [options.onStepDone] - Callback(step, result) fired on success.
12
12
  * @param {function} [options.onStepFail] - Callback(step, error) fired on failure.
13
+ * @param {function} [options.onWaveStart] - Callback(waveNumber, steps) fired before each wave executes.
13
14
  * @returns {Promise<Array<{id: string, status: string, result?: *, error?: string}>>}
14
15
  * @throws {Error} `[runPlan] steps must be a non-empty array` — when steps is not a non-empty array.
15
16
  * @throws {Error} `[runPlan] executeFn must be a function` — when executeFn is not a function.
@@ -47,7 +48,9 @@ async function runPlan(steps, executeFn, options = {}) {
47
48
  }
48
49
  }
49
50
 
50
- const { concurrency = Infinity, stateMachine, onStepStart, onStepDone, onStepFail } = options;
51
+ const { concurrency = Infinity, stateMachine, onStepStart, onStepDone, onStepFail, onWaveStart, stepRetry } = options;
52
+
53
+ let waveNumber = 0;
51
54
 
52
55
  // Wave loop
53
56
  while (true) {
@@ -81,6 +84,9 @@ async function runPlan(steps, executeFn, options = {}) {
81
84
  // Apply concurrency limit
82
85
  const wave = ready.slice(0, concurrency);
83
86
 
87
+ waveNumber++;
88
+ onWaveStart?.(waveNumber, wave.map(e => e.step));
89
+
84
90
  // Execute wave
85
91
  await Promise.all(wave.map(async entry => {
86
92
  const { step } = entry;
@@ -89,7 +95,9 @@ async function runPlan(steps, executeFn, options = {}) {
89
95
  onStepStart?.(step);
90
96
 
91
97
  try {
92
- const result = await executeFn(step);
98
+ const result = stepRetry
99
+ ? await stepRetry.call(() => executeFn(step))
100
+ : await executeFn(step);
93
101
  entry.status = 'done';
94
102
  entry.result = result;
95
103
  stateMachine?.transition(step.id, 'complete', result);
@@ -0,0 +1,3 @@
1
+ 'use strict';
2
+ const { JsonlTransport } = require('./transport-jsonl');
3
+ module.exports = { JsonlTransport };