bare-agent 0.1.1 → 0.2.1

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 ~1500 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
 
@@ -217,12 +217,19 @@ Same pattern works from Go, Rust, Java, Ruby — any language that can spawn a p
217
217
  required: 0
218
218
  optional: cron-parser (for cron expressions in scheduler)
219
219
  peer: better-sqlite3 (for SQLite memory store)
220
- total lines: ~820
220
+ total lines: ~1500
221
221
  ```
222
222
 
223
223
  ## Status
224
224
 
225
- Early development. Core components built and validated through POCs. See [project plan](docs/01-product/prd.md) for the full design.
225
+ **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:
226
+
227
+ - **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`.
228
+ - **Fragile glue code** — Manual wave execution, dependency resolution, and error propagation replaced by `runPlan` with built-in parallelism and failure cascading.
229
+ - **Provider lock-in** — Switching from OpenAI to Anthropic to CLIPipe required zero orchestration changes — just swap the provider constructor.
230
+ - **Debugging friction** — Structured `[ComponentName]` error prefixes and `Stream` events made failures traceable in minutes instead of hours.
231
+
232
+ See [project plan](docs/01-product/prd.md) for the full design. See [CHANGELOG.md](CHANGELOG.md) for release history.
226
233
 
227
234
  ## License
228
235
 
package/index.js CHANGED
@@ -8,6 +8,7 @@ const { Checkpoint } = require('./src/checkpoint');
8
8
  const { Memory } = require('./src/memory');
9
9
  const { Stream } = require('./src/stream');
10
10
  const { Retry } = require('./src/retry');
11
+ const { runPlan } = require('./src/run-plan');
11
12
 
12
13
  module.exports = {
13
14
  Loop,
@@ -18,4 +19,5 @@ module.exports = {
18
19
  Memory,
19
20
  Stream,
20
21
  Retry,
22
+ runPlan,
21
23
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bare-agent",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "files": [
5
5
  "index.js",
6
6
  "src/",
@@ -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"
package/src/loop.js CHANGED
@@ -9,6 +9,7 @@ class Loop {
9
9
  * @param {object} [options.checkpoint] - Checkpoint instance for human-in-the-loop.
10
10
  * @param {object} [options.retry] - Retry instance for backoff on failures.
11
11
  * @param {object} [options.stream] - Stream instance for event emission.
12
+ * @param {object} [options.store] - Store instance for validate() health check.
12
13
  * @throws {Error} `[Loop] requires a provider` — when options.provider is missing.
13
14
  */
14
15
  constructor(options = {}) {
@@ -22,6 +23,7 @@ class Loop {
22
23
  this.onToolCall = options.onToolCall || null;
23
24
  this.onText = options.onText || null;
24
25
  this.onError = options.onError || null;
26
+ this.store = options.store || null;
25
27
  this._stopped = false;
26
28
  this._history = []; // for chat() stateful mode
27
29
  }
@@ -146,6 +148,69 @@ class Loop {
146
148
  return { text: '', toolCalls: [], usage: lastUsage, error: warning };
147
149
  }
148
150
 
151
+ /**
152
+ * Health check — validates provider, store, and tools without throwing.
153
+ * @param {Array<object>} [tools=[]] - Tool definitions to validate.
154
+ * @returns {Promise<{provider: {ok: boolean, error?: string}, store: {ok: boolean, error?: string, skipped: boolean}, tools: {ok: boolean, errors?: string[]}}>}
155
+ * Never throws — all failures captured in return value.
156
+ */
157
+ async validate(tools = []) {
158
+ const result = {
159
+ provider: { ok: false },
160
+ store: { ok: false, skipped: false },
161
+ tools: { ok: true },
162
+ };
163
+
164
+ // Provider check
165
+ try {
166
+ await this.provider.generate([{ role: 'user', content: 'respond with ok' }], [], {});
167
+ result.provider.ok = true;
168
+ } catch (err) {
169
+ result.provider.error = err.message;
170
+ }
171
+
172
+ // Store check
173
+ if (!this.store) {
174
+ result.store.ok = true;
175
+ result.store.skipped = true;
176
+ } else {
177
+ try {
178
+ const testKey = `__validate_${Date.now()}`;
179
+ await this.store.store(testKey, { test: true });
180
+ const got = await this.store.get(testKey);
181
+ if (got === null || got === undefined) {
182
+ result.store.error = 'store.get returned null for test key';
183
+ } else {
184
+ await this.store.delete(testKey);
185
+ result.store.ok = true;
186
+ }
187
+ } catch (err) {
188
+ result.store.error = err.message;
189
+ }
190
+ }
191
+
192
+ // Tools check
193
+ const toolErrors = [];
194
+ for (const tool of tools) {
195
+ if (typeof tool.name !== 'string' || !tool.name) {
196
+ toolErrors.push(`Tool is missing a name (got ${JSON.stringify(tool.name)})`);
197
+ continue;
198
+ }
199
+ if (typeof tool.execute !== 'function') {
200
+ toolErrors.push(`Tool "${tool.name}" is missing an execute() function`);
201
+ }
202
+ if (tool.parameters !== undefined && (typeof tool.parameters !== 'object' || tool.parameters === null)) {
203
+ toolErrors.push(`Tool "${tool.name}" has invalid parameters — expected an object, got ${typeof tool.parameters}`);
204
+ }
205
+ }
206
+ if (toolErrors.length > 0) {
207
+ result.tools.ok = false;
208
+ result.tools.errors = toolErrors;
209
+ }
210
+
211
+ return result;
212
+ }
213
+
149
214
  async chat(text, tools = [], options = {}) {
150
215
  this._history.push({ role: 'user', content: text });
151
216
  const result = await this.run(this._history, tools, options);
@@ -0,0 +1,128 @@
1
+ 'use strict';
2
+
3
+ const { spawn } = require('child_process');
4
+
5
+ class CLIPipeProvider {
6
+ /**
7
+ * Provider that pipes prompts to a CLI command via stdin and reads stdout.
8
+ * @param {object} options
9
+ * @param {string} options.command - CLI command to spawn (required).
10
+ * @param {string[]} [options.args=[]] - Arguments to pass to the command.
11
+ * @param {string} [options.cwd] - Working directory for the child process.
12
+ * @param {object} [options.env] - Environment variables for the child process.
13
+ * @param {number} [options.timeout=30000] - Timeout in milliseconds.
14
+ * @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.
15
+ * @throws {Error} `[CLIPipeProvider] requires command` — when options.command is missing.
16
+ */
17
+ constructor(options = {}) {
18
+ if (!options.command) throw new Error('[CLIPipeProvider] requires command');
19
+ this.command = options.command;
20
+ this.args = options.args || [];
21
+ this.cwd = options.cwd || undefined;
22
+ this.env = options.env || undefined;
23
+ this.timeout = options.timeout ?? 30000;
24
+ this.systemPromptFlag = options.systemPromptFlag || null;
25
+ }
26
+
27
+ /**
28
+ * Generate a response by piping messages to the CLI command.
29
+ * @param {Array<object>} messages - Conversation messages in OpenAI format.
30
+ * @param {Array<object>} [tools=[]] - Unused (CLI commands don't support tools).
31
+ * @param {object} [options={}] - Unused.
32
+ * @returns {Promise<{text: string, toolCalls: Array, usage: object}>}
33
+ * @throws {Error} `[CLIPipeProvider] failed to spawn "cmd": ...` — when the command cannot be found or executed.
34
+ * @throws {Error} `[CLIPipeProvider] process exited with code N: ...` — on non-zero exit.
35
+ * @throws {Error} `[CLIPipeProvider] timed out after Nms` — when the process exceeds timeout.
36
+ * @throws {Error} `[CLIPipeProvider] process produced no output` — when stdout is empty.
37
+ */
38
+ async generate(messages, tools = [], options = {}) {
39
+ let extraArgs = [];
40
+ let promptMessages = messages;
41
+
42
+ if (this.systemPromptFlag) {
43
+ const systemMessages = messages.filter(m => m.role === 'system');
44
+ if (systemMessages.length > 0) {
45
+ const systemContent = systemMessages.map(m => m.content).join('\n\n');
46
+ extraArgs = [this.systemPromptFlag, systemContent];
47
+ promptMessages = messages.filter(m => m.role !== 'system');
48
+ }
49
+ }
50
+
51
+ const prompt = this._formatPrompt(promptMessages);
52
+ const text = await this._spawn(prompt, extraArgs);
53
+ return {
54
+ text,
55
+ toolCalls: [],
56
+ usage: { inputTokens: 0, outputTokens: 0 },
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Convert OpenAI-format messages to a plain text prompt.
62
+ * @param {Array<object>} messages
63
+ * @returns {string}
64
+ */
65
+ _formatPrompt(messages) {
66
+ return messages.map(m => {
67
+ const role = m.role.charAt(0).toUpperCase() + m.role.slice(1);
68
+ return `${role}: ${m.content}`;
69
+ }).join('\n') + '\n';
70
+ }
71
+
72
+ /**
73
+ * Spawn the CLI process, pipe prompt to stdin, collect stdout.
74
+ * @param {string} prompt
75
+ * @param {string[]} [extraArgs=[]] - Additional args prepended to this.args.
76
+ * @returns {Promise<string>}
77
+ */
78
+ _spawn(prompt, extraArgs = []) {
79
+ return new Promise((resolve, reject) => {
80
+ const child = spawn(this.command, [...this.args, ...extraArgs], {
81
+ cwd: this.cwd,
82
+ env: this.env,
83
+ stdio: ['pipe', 'pipe', 'pipe'],
84
+ });
85
+
86
+ let stdout = '';
87
+ let stderr = '';
88
+ let killed = false;
89
+
90
+ child.stdout.on('data', d => { stdout += d; });
91
+ child.stderr.on('data', d => { stderr += d; });
92
+
93
+ child.on('error', err => {
94
+ reject(new Error(`[CLIPipeProvider] failed to spawn "${this.command}": ${err.message}`));
95
+ });
96
+
97
+ child.on('close', code => {
98
+ if (killed) return; // timeout already rejected
99
+ if (code !== 0) {
100
+ return reject(new Error(`[CLIPipeProvider] process exited with code ${code}: ${stderr.trim()}`));
101
+ }
102
+ const text = stdout.trim();
103
+ if (!text) {
104
+ return reject(new Error('[CLIPipeProvider] process produced no output'));
105
+ }
106
+ resolve(text);
107
+ });
108
+
109
+ // Timeout handling
110
+ const timer = setTimeout(() => {
111
+ killed = true;
112
+ child.kill('SIGTERM');
113
+ setTimeout(() => {
114
+ try { child.kill('SIGKILL'); } catch (_) {}
115
+ }, 1000);
116
+ reject(new Error(`[CLIPipeProvider] timed out after ${this.timeout}ms`));
117
+ }, this.timeout);
118
+
119
+ child.on('close', () => clearTimeout(timer));
120
+
121
+ // Write prompt to stdin — catch errors silently (process may exit early)
122
+ child.stdin.on('error', () => {});
123
+ child.stdin.end(prompt);
124
+ });
125
+ }
126
+ }
127
+
128
+ module.exports = { CLIPipeProvider };
package/src/providers.js CHANGED
@@ -3,9 +3,11 @@
3
3
  const { OpenAIProvider } = require('./provider-openai');
4
4
  const { AnthropicProvider } = require('./provider-anthropic');
5
5
  const { OllamaProvider } = require('./provider-ollama');
6
+ const { CLIPipeProvider } = require('./provider-clipipe');
6
7
 
7
8
  module.exports = {
8
9
  OpenAI: OpenAIProvider,
9
10
  Anthropic: AnthropicProvider,
10
11
  Ollama: OllamaProvider,
12
+ CLIPipe: CLIPipeProvider,
11
13
  };
@@ -0,0 +1,122 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Execute a step DAG with wave-based parallelism.
5
+ * @param {Array<{id: string, action: string, dependsOn?: string[]}>} steps - Steps from Planner.
6
+ * @param {function} executeFn - Async function called for each step: (step) => result.
7
+ * @param {object} [options={}]
8
+ * @param {number} [options.concurrency=Infinity] - Max parallel steps per wave.
9
+ * @param {object} [options.stateMachine] - StateMachine instance for lifecycle tracking.
10
+ * @param {function} [options.onStepStart] - Callback(step) fired when a step begins.
11
+ * @param {function} [options.onStepDone] - Callback(step, result) fired on success.
12
+ * @param {function} [options.onStepFail] - Callback(step, error) fired on failure.
13
+ * @param {function} [options.onWaveStart] - Callback(waveNumber, steps) fired before each wave executes.
14
+ * @returns {Promise<Array<{id: string, status: string, result?: *, error?: string}>>}
15
+ * @throws {Error} `[runPlan] steps must be a non-empty array` — when steps is not a non-empty array.
16
+ * @throws {Error} `[runPlan] executeFn must be a function` — when executeFn is not a function.
17
+ * @throws {Error} `[runPlan] duplicate step id: "X"` — when two steps share an id.
18
+ * @throws {Error} `[runPlan] step "X" depends on unknown step "Y"` — when dependsOn references missing id.
19
+ */
20
+ async function runPlan(steps, executeFn, options = {}) {
21
+ if (!Array.isArray(steps) || steps.length === 0) {
22
+ throw new Error('[runPlan] steps must be a non-empty array');
23
+ }
24
+ if (typeof executeFn !== 'function') {
25
+ throw new Error('[runPlan] executeFn must be a function');
26
+ }
27
+
28
+ // Build tracking map (don't mutate input)
29
+ const tracking = new Map();
30
+ for (const step of steps) {
31
+ if (tracking.has(step.id)) {
32
+ throw new Error(`[runPlan] duplicate step id: "${step.id}"`);
33
+ }
34
+ tracking.set(step.id, {
35
+ step: { ...step },
36
+ status: 'pending',
37
+ result: undefined,
38
+ error: undefined,
39
+ });
40
+ }
41
+
42
+ // Validate dependencies
43
+ for (const step of steps) {
44
+ for (const dep of (step.dependsOn || [])) {
45
+ if (!tracking.has(dep)) {
46
+ throw new Error(`[runPlan] step "${step.id}" depends on unknown step "${dep}"`);
47
+ }
48
+ }
49
+ }
50
+
51
+ const { concurrency = Infinity, stateMachine, onStepStart, onStepDone, onStepFail, onWaveStart } = options;
52
+
53
+ let waveNumber = 0;
54
+
55
+ // Wave loop
56
+ while (true) {
57
+ // Propagate dependency failures
58
+ for (const [id, entry] of tracking) {
59
+ if (entry.status !== 'pending') continue;
60
+ for (const dep of (entry.step.dependsOn || [])) {
61
+ const depEntry = tracking.get(dep);
62
+ if (depEntry.status === 'failed') {
63
+ entry.status = 'failed';
64
+ entry.error = `dependency '${dep}' failed`;
65
+ stateMachine?.transition(id, 'start');
66
+ stateMachine?.transition(id, 'fail', entry.error);
67
+ onStepFail?.(entry.step, new Error(entry.error));
68
+ break;
69
+ }
70
+ }
71
+ }
72
+
73
+ // Find ready steps: pending + all deps done
74
+ const ready = [];
75
+ for (const [id, entry] of tracking) {
76
+ if (entry.status !== 'pending') continue;
77
+ const deps = entry.step.dependsOn || [];
78
+ const allDone = deps.every(dep => tracking.get(dep).status === 'done');
79
+ if (allDone) ready.push(entry);
80
+ }
81
+
82
+ if (ready.length === 0) break;
83
+
84
+ // Apply concurrency limit
85
+ const wave = ready.slice(0, concurrency);
86
+
87
+ waveNumber++;
88
+ onWaveStart?.(waveNumber, wave.map(e => e.step));
89
+
90
+ // Execute wave
91
+ await Promise.all(wave.map(async entry => {
92
+ const { step } = entry;
93
+ entry.status = 'running';
94
+ stateMachine?.transition(step.id, 'start');
95
+ onStepStart?.(step);
96
+
97
+ try {
98
+ const result = await executeFn(step);
99
+ entry.status = 'done';
100
+ entry.result = result;
101
+ stateMachine?.transition(step.id, 'complete', result);
102
+ onStepDone?.(step, result);
103
+ } catch (err) {
104
+ entry.status = 'failed';
105
+ entry.error = err.message;
106
+ stateMachine?.transition(step.id, 'fail', err.message);
107
+ onStepFail?.(step, err);
108
+ }
109
+ }));
110
+ }
111
+
112
+ // Return results in original order
113
+ return steps.map(s => {
114
+ const entry = tracking.get(s.id);
115
+ const out = { id: s.id, status: entry.status };
116
+ if (entry.result !== undefined) out.result = entry.result;
117
+ if (entry.error !== undefined) out.error = entry.error;
118
+ return out;
119
+ });
120
+ }
121
+
122
+ module.exports = { runPlan };
@@ -0,0 +1,3 @@
1
+ 'use strict';
2
+ const { JsonlTransport } = require('./transport-jsonl');
3
+ module.exports = { JsonlTransport };