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 +10 -3
- package/index.js +2 -0
- package/package.json +3 -2
- package/src/loop.js +65 -0
- package/src/provider-clipipe.js +128 -0
- package/src/providers.js +2 -0
- package/src/run-plan.js +122 -0
- package/src/transports.js +3 -0
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
# bare-agent
|
|
15
15
|
|
|
16
|
-
**Agent orchestration in ~
|
|
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: ~
|
|
220
|
+
total lines: ~1500
|
|
221
221
|
```
|
|
222
222
|
|
|
223
223
|
## Status
|
|
224
224
|
|
|
225
|
-
|
|
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.
|
|
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
|
};
|
package/src/run-plan.js
ADDED
|
@@ -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 };
|