bare-agent 0.1.0 → 0.1.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 +229 -0
- package/bin/cli.js +71 -0
- package/index.js +21 -1
- package/package.json +40 -5
- package/src/checkpoint.js +33 -0
- package/src/loop.js +163 -0
- package/src/memory.js +46 -0
- package/src/planner.js +81 -0
- package/src/provider-anthropic.js +144 -0
- package/src/provider-ollama.js +87 -0
- package/src/provider-openai.js +91 -0
- package/src/providers.js +11 -0
- package/src/retry.js +53 -0
- package/src/scheduler.js +129 -0
- package/src/state.js +86 -0
- package/src/store-jsonfile.js +63 -0
- package/src/store-sqlite.js +133 -0
- package/src/stores.js +9 -0
- package/src/stream.js +41 -0
- package/src/transport-jsonl.js +19 -0
package/README.md
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
```
|
|
2
|
+
╭─────────────────────────────────╮
|
|
3
|
+
│ ╔╗ ╔═╗╦═╗╔═╗ ╔═╗╔═╗╔═╗╔╗╔╔╦╗ │
|
|
4
|
+
│ ╠╩╗╠═╣╠╦╝╠╣ ╠═╣║ ╦╠╣ ║║║ ║ │
|
|
5
|
+
│ ╚═╝╩ ╩╩╚═╚═╝ ╩ ╩╚═╝╚═╝╝╚╝ ╩ │
|
|
6
|
+
│ think ──→ act ──→ observe │
|
|
7
|
+
│ ↑ │ │
|
|
8
|
+
│ └──────────────────┘ │
|
|
9
|
+
╰──╮──────────────────────────────╯
|
|
10
|
+
╰── the brain, without the bloat
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
# bare-agent
|
|
15
|
+
|
|
16
|
+
**Agent orchestration in ~800 lines. Zero required deps. MIT license.**
|
|
17
|
+
|
|
18
|
+
Everything between "call the LLM" and "ship the agent" — loop, plan, remember, schedule, checkpoint. Each works alone. All compose together.
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
npm install bare-agent
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## Why this exists
|
|
27
|
+
|
|
28
|
+
You want to build an agent. You have two choices:
|
|
29
|
+
|
|
30
|
+
1. **Write it from scratch** — 250+ lines of boilerplate. Tool calling loop, retries, provider normalization, memory, state tracking. Everyone reinvents this.
|
|
31
|
+
2. **Adopt a framework** — 50,000 lines, 200 deps, middleware chains, lifecycle hooks, plugin systems. 95% of it is irrelevant to your use case.
|
|
32
|
+
|
|
33
|
+
**bare-agent is the middle ground.** Small enough to read in an afternoon. Complete enough that you stop reimplementing the same patterns. Each piece works alone — take what you need, ignore the rest.
|
|
34
|
+
|
|
35
|
+
Not a framework. Not an SDK. Just composable building blocks for agents.
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Architecture
|
|
40
|
+
|
|
41
|
+
Three layers. You use the first two. You bring the third.
|
|
42
|
+
|
|
43
|
+
### Layer 1: ORCHESTRATION — who does what? in what order? what when things go wrong?
|
|
44
|
+
|
|
45
|
+
| Component | What it does | How |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| **Planner** | Goal -> step DAG | Structured output prompt, LLM returns JSON dependency graph |
|
|
48
|
+
| **State** | Task lifecycle tracking | `pending -> running -> done \| failed`, persisted to JSON file |
|
|
49
|
+
| **Stream** | Event streaming | One JSON object per line to stdout, pipe-friendly, any-language |
|
|
50
|
+
|
|
51
|
+
### Layer 2: EXECUTION — how the agent thinks, remembers, acts, and persist?
|
|
52
|
+
|
|
53
|
+
| Component | What it does | How |
|
|
54
|
+
|---|---|---|
|
|
55
|
+
| **Loop** | Think -> act -> observe | Calls OpenAI/Anthropic/Ollama, executes tools, loops until text |
|
|
56
|
+
| **Scheduler** | Time-triggered turns | Cron (`0 7 * * 1-5`), relative (`2h`, `30m`), persisted jobs |
|
|
57
|
+
| **Memory** | Persist + search | SQLite FTS5 with BM25 (default), JSON file fallback (zero deps) |
|
|
58
|
+
| **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
|
+
|
|
61
|
+
### Layer 3: ACTUATION — you provide this
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
bare-agent provides the brain. You provide the hands.
|
|
65
|
+
Your tools plug into the Loop as functions:
|
|
66
|
+
|
|
67
|
+
REST APIs Gmail, Spotify, Calendar, any HTTP endpoint
|
|
68
|
+
MCP servers any MCP-compatible tool server
|
|
69
|
+
CLI commands termux-api, ffmpeg, git, shell scripts
|
|
70
|
+
Browser Playwright, Puppeteer
|
|
71
|
+
UI automation ADB, accessibility APIs
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
bare-agent does not ship tools. Your tools plug into the Loop as functions — `{ name, description, parameters, execute }`. The library handles orchestration. You handle action.
|
|
75
|
+
|
|
76
|
+
### What bare-agent does NOT do
|
|
77
|
+
|
|
78
|
+
| Not included | Why | Use instead |
|
|
79
|
+
|---|---|---|
|
|
80
|
+
| Tool implementations | Actuation is your domain | Your APIs, MCP servers, CLI commands |
|
|
81
|
+
| Web UI / dashboard | AG-UI protocol exists | CopilotKit, or build your own |
|
|
82
|
+
| Authentication | Every app has different auth | Wrap Checkpoint with your auth |
|
|
83
|
+
| Browser automation | Separate concern, too heavy | Playwright, Puppeteer (as a tool) |
|
|
84
|
+
| Multi-tenant isolation | Platform problem, not agent problem | Build on top with scope filtering |
|
|
85
|
+
| Agent-to-agent protocol | A2A exists for this | Use A2A SDK when needed |
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## Quick start
|
|
90
|
+
|
|
91
|
+
### Minimal — 10 lines, one LLM call with tools
|
|
92
|
+
|
|
93
|
+
```javascript
|
|
94
|
+
const { Loop } = require('bare-agent');
|
|
95
|
+
const { OpenAIProvider } = require('bare-agent/providers');
|
|
96
|
+
|
|
97
|
+
const loop = new Loop({
|
|
98
|
+
provider: new OpenAIProvider({ apiKey: process.env.OPENAI_API_KEY }),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const result = await loop.run([
|
|
102
|
+
{ role: 'user', content: 'What is the weather in Berlin?' }
|
|
103
|
+
], [weatherTool]);
|
|
104
|
+
|
|
105
|
+
console.log(result.text);
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### With human approval — 30 lines
|
|
109
|
+
|
|
110
|
+
```javascript
|
|
111
|
+
const { Loop, Checkpoint } = require('bare-agent');
|
|
112
|
+
const { AnthropicProvider } = require('bare-agent/providers');
|
|
113
|
+
|
|
114
|
+
const checkpoint = new Checkpoint({
|
|
115
|
+
tools: ['send_email'],
|
|
116
|
+
send: (q) => console.log(`[APPROVE?] ${q}`),
|
|
117
|
+
waitForReply: () => new Promise(resolve =>
|
|
118
|
+
process.stdin.once('data', d => resolve(d.toString().trim()))
|
|
119
|
+
),
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const loop = new Loop({
|
|
123
|
+
provider: new AnthropicProvider({ apiKey: process.env.ANTHROPIC_API_KEY }),
|
|
124
|
+
checkpoint,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const result = await loop.run([
|
|
128
|
+
{ role: 'user', content: 'Email mom that I will be late' }
|
|
129
|
+
], [emailTool]);
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### Full autonomous agent — 40 lines
|
|
133
|
+
|
|
134
|
+
```javascript
|
|
135
|
+
const { Loop, Planner, StateMachine, Scheduler,
|
|
136
|
+
Memory, Checkpoint, Stream, Retry } = require('bare-agent');
|
|
137
|
+
const { AnthropicProvider } = require('bare-agent/providers');
|
|
138
|
+
const { SQLiteStore } = require('bare-agent/stores');
|
|
139
|
+
|
|
140
|
+
const provider = new AnthropicProvider({
|
|
141
|
+
apiKey: process.env.ANTHROPIC_API_KEY,
|
|
142
|
+
model: 'claude-haiku-4-5-20251001',
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const loop = new Loop({
|
|
146
|
+
provider,
|
|
147
|
+
planner: new Planner({ provider }),
|
|
148
|
+
state: new StateMachine({ file: './tasks.json' }),
|
|
149
|
+
memory: new Memory({ store: new SQLiteStore('./agent.db') }),
|
|
150
|
+
checkpoint: new Checkpoint({
|
|
151
|
+
tools: ['purchase', 'send_email'],
|
|
152
|
+
send: (q) => telegram.send(chatId, q),
|
|
153
|
+
waitForReply: () => new Promise(r => telegram.once('message', r)),
|
|
154
|
+
}),
|
|
155
|
+
stream: new Stream({ transport: 'jsonl' }),
|
|
156
|
+
retry: new Retry({ maxAttempts: 3, backoff: 'exponential' }),
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
await loop.runGoal('Book my Berlin trip for next Tuesday');
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
## LLM Providers
|
|
165
|
+
|
|
166
|
+
Three built-in. All implement one method: `generate(messages, tools, options) -> { text, toolCalls, usage }`.
|
|
167
|
+
|
|
168
|
+
| Provider | Covers |
|
|
169
|
+
|---|---|
|
|
170
|
+
| **OpenAI** | OpenAI, OpenRouter, Together, Groq, vLLM, LM Studio — any OpenAI-compatible endpoint |
|
|
171
|
+
| **Anthropic** | Claude models via native API |
|
|
172
|
+
| **Ollama** | Local models, no API key needed |
|
|
173
|
+
| **Bring your own** | Implement `generate()` — one method, full control |
|
|
174
|
+
|
|
175
|
+
## Storage
|
|
176
|
+
|
|
177
|
+
| Store | Deps | Search |
|
|
178
|
+
|---|---|---|
|
|
179
|
+
| **SQLite FTS5** | `better-sqlite3` (peer dep) | Full-text search with BM25 ranking |
|
|
180
|
+
| **JSON file** | None | Substring matching |
|
|
181
|
+
| **Bring your own** | None | Implement 4 methods for Postgres, Redis, etc. |
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## Cross-language usage
|
|
186
|
+
|
|
187
|
+
bare-agent runs as a subprocess. Communicate via JSONL on stdin/stdout. Works from any language.
|
|
188
|
+
|
|
189
|
+
```python
|
|
190
|
+
import subprocess, json
|
|
191
|
+
|
|
192
|
+
proc = subprocess.Popen(
|
|
193
|
+
['npx', 'bare-agent', '--jsonl'],
|
|
194
|
+
stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
proc.stdin.write(json.dumps({
|
|
198
|
+
"method": "run",
|
|
199
|
+
"params": {"goal": "What is 2+2?"}
|
|
200
|
+
}) + '\n')
|
|
201
|
+
proc.stdin.flush()
|
|
202
|
+
|
|
203
|
+
for line in proc.stdout:
|
|
204
|
+
event = json.loads(line)
|
|
205
|
+
if event['type'] == 'loop:done':
|
|
206
|
+
print(event['data']['text'])
|
|
207
|
+
break
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Same pattern works from Go, Rust, Java, Ruby — any language that can spawn a process and read lines.
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
## Dependencies
|
|
215
|
+
|
|
216
|
+
```
|
|
217
|
+
required: 0
|
|
218
|
+
optional: cron-parser (for cron expressions in scheduler)
|
|
219
|
+
peer: better-sqlite3 (for SQLite memory store)
|
|
220
|
+
total lines: ~820
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
## Status
|
|
224
|
+
|
|
225
|
+
Early development. Core components built and validated through POCs. See [project plan](docs/01-product/prd.md) for the full design.
|
|
226
|
+
|
|
227
|
+
## License
|
|
228
|
+
|
|
229
|
+
MIT
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { createInterface } = require('node:readline');
|
|
5
|
+
const { Loop } = require('../src/loop');
|
|
6
|
+
const { Stream } = require('../src/stream');
|
|
7
|
+
const { JsonlTransport } = require('../src/transport-jsonl');
|
|
8
|
+
|
|
9
|
+
const args = process.argv.slice(2);
|
|
10
|
+
const flag = (name) => {
|
|
11
|
+
const i = args.indexOf(`--${name}`);
|
|
12
|
+
return i >= 0 ? args[i + 1] : undefined;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const providerName = flag('provider') || 'openai';
|
|
16
|
+
const model = flag('model');
|
|
17
|
+
|
|
18
|
+
function createProvider() {
|
|
19
|
+
if (providerName === 'openai') {
|
|
20
|
+
const { OpenAIProvider } = require('../src/provider-openai');
|
|
21
|
+
return new OpenAIProvider({
|
|
22
|
+
apiKey: process.env.OPENAI_API_KEY,
|
|
23
|
+
...(model && { model }),
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
if (providerName === 'anthropic') {
|
|
27
|
+
const { AnthropicProvider } = require('../src/provider-anthropic');
|
|
28
|
+
return new AnthropicProvider({
|
|
29
|
+
apiKey: process.env.ANTHROPIC_API_KEY,
|
|
30
|
+
...(model && { model }),
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
if (providerName === 'ollama') {
|
|
34
|
+
const { OllamaProvider } = require('../src/provider-ollama');
|
|
35
|
+
return new OllamaProvider({
|
|
36
|
+
...(model && { model }),
|
|
37
|
+
...(flag('url') && { url: flag('url') }),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
process.stderr.write(`Unknown provider: ${providerName}\n`);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const stream = new Stream({ transport: new JsonlTransport() });
|
|
45
|
+
const loop = new Loop({ provider: createProvider(), stream });
|
|
46
|
+
|
|
47
|
+
let pending = 0;
|
|
48
|
+
let closing = false;
|
|
49
|
+
|
|
50
|
+
const rl = createInterface({ input: process.stdin });
|
|
51
|
+
rl.on('line', async (line) => {
|
|
52
|
+
pending++;
|
|
53
|
+
try {
|
|
54
|
+
const req = JSON.parse(line);
|
|
55
|
+
const messages = req.params?.messages || [
|
|
56
|
+
{ role: 'user', content: req.params?.goal || '' },
|
|
57
|
+
];
|
|
58
|
+
const result = await loop.run(messages, []);
|
|
59
|
+
stream.emit({ type: 'result', data: result });
|
|
60
|
+
} catch (err) {
|
|
61
|
+
stream.emit({ type: 'error', data: { error: err.message } });
|
|
62
|
+
} finally {
|
|
63
|
+
pending--;
|
|
64
|
+
if (closing && pending === 0) process.exit(0);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
rl.on('close', () => {
|
|
69
|
+
closing = true;
|
|
70
|
+
if (pending === 0) process.exit(0);
|
|
71
|
+
});
|
package/index.js
CHANGED
|
@@ -1 +1,21 @@
|
|
|
1
|
-
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { Loop } = require('./src/loop');
|
|
4
|
+
const { Planner } = require('./src/planner');
|
|
5
|
+
const { StateMachine } = require('./src/state');
|
|
6
|
+
const { Scheduler } = require('./src/scheduler');
|
|
7
|
+
const { Checkpoint } = require('./src/checkpoint');
|
|
8
|
+
const { Memory } = require('./src/memory');
|
|
9
|
+
const { Stream } = require('./src/stream');
|
|
10
|
+
const { Retry } = require('./src/retry');
|
|
11
|
+
|
|
12
|
+
module.exports = {
|
|
13
|
+
Loop,
|
|
14
|
+
Planner,
|
|
15
|
+
StateMachine,
|
|
16
|
+
Scheduler,
|
|
17
|
+
Checkpoint,
|
|
18
|
+
Memory,
|
|
19
|
+
Stream,
|
|
20
|
+
Retry,
|
|
21
|
+
};
|
package/package.json
CHANGED
|
@@ -1,16 +1,51 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bare-agent",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"files": [
|
|
5
|
+
"index.js",
|
|
6
|
+
"src/",
|
|
7
|
+
"bin/"
|
|
8
|
+
],
|
|
9
|
+
"description": "Lightweight, composable agent orchestration. ~800 lines, 0 required deps.",
|
|
5
10
|
"license": "MIT",
|
|
6
|
-
"author": "
|
|
7
|
-
"keywords": ["agent", "llm", "orchestration", "ai", "tool-calling", "planner", "lightweight"],
|
|
11
|
+
"author": "hamr0",
|
|
8
12
|
"repository": {
|
|
9
13
|
"type": "git",
|
|
10
|
-
"url": "https://github.com/
|
|
14
|
+
"url": "git+https://github.com/hamr0/bareagent.git"
|
|
11
15
|
},
|
|
12
16
|
"main": "index.js",
|
|
17
|
+
"bin": {
|
|
18
|
+
"bare-agent": "./bin/cli.js"
|
|
19
|
+
},
|
|
20
|
+
"exports": {
|
|
21
|
+
".": "./index.js",
|
|
22
|
+
"./providers": "./src/providers.js",
|
|
23
|
+
"./stores": "./src/stores.js"
|
|
24
|
+
},
|
|
13
25
|
"engines": {
|
|
14
26
|
"node": ">=18"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"agent",
|
|
30
|
+
"llm",
|
|
31
|
+
"orchestration",
|
|
32
|
+
"ai",
|
|
33
|
+
"tool-calling",
|
|
34
|
+
"planner",
|
|
35
|
+
"lightweight"
|
|
36
|
+
],
|
|
37
|
+
"optionalDependencies": {
|
|
38
|
+
"cron-parser": "^4.9.0"
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"better-sqlite3": "^12.6.2"
|
|
42
|
+
},
|
|
43
|
+
"peerDependenciesMeta": {
|
|
44
|
+
"better-sqlite3": {
|
|
45
|
+
"optional": true
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"scripts": {
|
|
49
|
+
"test": "node --test test/**/*.test.js"
|
|
15
50
|
}
|
|
16
51
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
class Checkpoint {
|
|
4
|
+
constructor(options = {}) {
|
|
5
|
+
this.tools = new Set(options.tools || []);
|
|
6
|
+
this.send = options.send || null;
|
|
7
|
+
this.waitForReply = options.waitForReply || null;
|
|
8
|
+
this.shouldAskFn = options.shouldAsk || null; // custom predicate override
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
shouldAsk(toolName, args) {
|
|
12
|
+
if (this.shouldAskFn) return this.shouldAskFn(toolName, args);
|
|
13
|
+
return this.tools.has(toolName);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Send a question and wait for a reply.
|
|
18
|
+
* @param {string} question - The approval question to send.
|
|
19
|
+
* @param {object} [context={}] - Context passed to send and waitForReply.
|
|
20
|
+
* @returns {Promise<string|null>} The user's reply, or null.
|
|
21
|
+
* @throws {Error} `[Checkpoint] send and waitForReply callbacks required` — when callbacks are missing.
|
|
22
|
+
*/
|
|
23
|
+
async ask(question, context = {}) {
|
|
24
|
+
if (!this.send || !this.waitForReply) {
|
|
25
|
+
throw new Error('[Checkpoint] send and waitForReply callbacks required');
|
|
26
|
+
}
|
|
27
|
+
await this.send(question, context);
|
|
28
|
+
const reply = await this.waitForReply(context);
|
|
29
|
+
return reply ?? null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = { Checkpoint };
|
package/src/loop.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
class Loop {
|
|
4
|
+
/**
|
|
5
|
+
* @param {object} options
|
|
6
|
+
* @param {object} options.provider - LLM provider (must implement generate()).
|
|
7
|
+
* @param {number} [options.maxRounds=5] - Maximum think/act/observe cycles.
|
|
8
|
+
* @param {string} [options.system] - System prompt prepended to messages.
|
|
9
|
+
* @param {object} [options.checkpoint] - Checkpoint instance for human-in-the-loop.
|
|
10
|
+
* @param {object} [options.retry] - Retry instance for backoff on failures.
|
|
11
|
+
* @param {object} [options.stream] - Stream instance for event emission.
|
|
12
|
+
* @throws {Error} `[Loop] requires a provider` — when options.provider is missing.
|
|
13
|
+
*/
|
|
14
|
+
constructor(options = {}) {
|
|
15
|
+
if (!options.provider) throw new Error('[Loop] requires a provider');
|
|
16
|
+
this.provider = options.provider;
|
|
17
|
+
this.maxRounds = options.maxRounds || 5;
|
|
18
|
+
this.system = options.system || null;
|
|
19
|
+
this.checkpoint = options.checkpoint || null;
|
|
20
|
+
this.retry = options.retry || null;
|
|
21
|
+
this.stream = options.stream || null;
|
|
22
|
+
this.onToolCall = options.onToolCall || null;
|
|
23
|
+
this.onText = options.onText || null;
|
|
24
|
+
this.onError = options.onError || null;
|
|
25
|
+
this._stopped = false;
|
|
26
|
+
this._history = []; // for chat() stateful mode
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Run the think/act/observe loop.
|
|
31
|
+
* @param {Array<object>} messages - Conversation messages in OpenAI format.
|
|
32
|
+
* @param {Array<object>} [tools=[]] - Tool definitions with name, execute, description, parameters.
|
|
33
|
+
* @param {object} [options={}] - Per-run overrides (system, temperature, etc.).
|
|
34
|
+
* @returns {Promise<{text: string, toolCalls: Array, usage: object, error: string|null}>}
|
|
35
|
+
* @throws {Error} `[Loop] Tool is missing a name` — when a tool has no name or a non-string name.
|
|
36
|
+
* @throws {Error} `[Loop] Tool "X" is missing an execute() function` — when execute is not a function.
|
|
37
|
+
* @throws {Error} `[Loop] Tool "X" has invalid parameters` — when parameters is not an object.
|
|
38
|
+
*/
|
|
39
|
+
async run(messages, tools = [], options = {}) {
|
|
40
|
+
this._stopped = false;
|
|
41
|
+
const system = options.system || this.system;
|
|
42
|
+
const msgs = system
|
|
43
|
+
? [{ role: 'system', content: system }, ...messages]
|
|
44
|
+
: [...messages];
|
|
45
|
+
const toolMap = new Map(tools.map(t => [t.name, t]));
|
|
46
|
+
|
|
47
|
+
// Validate tools at wire time
|
|
48
|
+
for (const tool of tools) {
|
|
49
|
+
if (typeof tool.name !== 'string' || !tool.name) {
|
|
50
|
+
throw new Error(`[Loop] Tool is missing a name (got ${JSON.stringify(tool.name)}). Every tool must have a non-empty string name.`);
|
|
51
|
+
}
|
|
52
|
+
if (typeof tool.execute !== 'function') {
|
|
53
|
+
throw new Error(`[Loop] Tool "${tool.name}" is missing an execute() function.`);
|
|
54
|
+
}
|
|
55
|
+
if (tool.description !== undefined && typeof tool.description !== 'string') {
|
|
56
|
+
console.warn(`[Loop] Tool "${tool.name}" has a non-string description — providers may ignore it.`);
|
|
57
|
+
}
|
|
58
|
+
if (tool.parameters !== undefined && (typeof tool.parameters !== 'object' || tool.parameters === null)) {
|
|
59
|
+
throw new Error(`[Loop] Tool "${tool.name}" has invalid parameters — expected an object, got ${typeof tool.parameters}.`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
this.stream?.emit({ type: 'loop:start', data: { messageCount: msgs.length } });
|
|
64
|
+
|
|
65
|
+
let lastUsage = { inputTokens: 0, outputTokens: 0 };
|
|
66
|
+
|
|
67
|
+
for (let round = 0; round < this.maxRounds; round++) {
|
|
68
|
+
if (this._stopped) break;
|
|
69
|
+
|
|
70
|
+
let result;
|
|
71
|
+
try {
|
|
72
|
+
const generate = () => this.provider.generate(msgs, tools, options);
|
|
73
|
+
result = this.retry ? await this.retry.call(generate) : await generate();
|
|
74
|
+
} catch (err) {
|
|
75
|
+
this.stream?.emit({ type: 'loop:error', data: { error: err.message, round } });
|
|
76
|
+
this.onError?.(err);
|
|
77
|
+
return { text: '', toolCalls: [], usage: lastUsage, error: err.message };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
lastUsage = result.usage || lastUsage;
|
|
81
|
+
|
|
82
|
+
// No tool calls — LLM gave a final text response
|
|
83
|
+
if (!result.toolCalls || result.toolCalls.length === 0) {
|
|
84
|
+
this.stream?.emit({ type: 'loop:text', data: { text: result.text } });
|
|
85
|
+
this.onText?.(result.text);
|
|
86
|
+
this.stream?.emit({ type: 'loop:done', data: { text: result.text, usage: lastUsage } });
|
|
87
|
+
return { text: result.text, toolCalls: [], usage: lastUsage, error: null };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Execute tool calls
|
|
91
|
+
msgs.push({
|
|
92
|
+
role: 'assistant',
|
|
93
|
+
content: result.text || null,
|
|
94
|
+
tool_calls: result.toolCalls.map(tc => ({
|
|
95
|
+
id: tc.id,
|
|
96
|
+
type: 'function',
|
|
97
|
+
function: { name: tc.name, arguments: JSON.stringify(tc.arguments) },
|
|
98
|
+
})),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
for (const tc of result.toolCalls) {
|
|
102
|
+
if (this._stopped) break;
|
|
103
|
+
|
|
104
|
+
const tool = toolMap.get(tc.name);
|
|
105
|
+
if (!tool) {
|
|
106
|
+
const errMsg = `[Loop] Unknown tool: ${tc.name}`;
|
|
107
|
+
msgs.push({ role: 'tool', tool_call_id: tc.id, content: errMsg });
|
|
108
|
+
this.stream?.emit({ type: 'loop:tool_result', data: { tool: tc.name, error: errMsg } });
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Checkpoint — ask for approval before executing
|
|
113
|
+
if (this.checkpoint?.shouldAsk(tc.name, tc.arguments)) {
|
|
114
|
+
this.stream?.emit({ type: 'checkpoint:ask', data: { tool: tc.name, args: tc.arguments } });
|
|
115
|
+
const reply = await this.checkpoint.ask(
|
|
116
|
+
`Approve ${tc.name}(${JSON.stringify(tc.arguments)})?`,
|
|
117
|
+
{ tool: tc.name, args: tc.arguments }
|
|
118
|
+
);
|
|
119
|
+
this.stream?.emit({ type: 'checkpoint:reply', data: { reply } });
|
|
120
|
+
if (!reply || reply.toLowerCase() === 'no' || reply.toLowerCase() === 'n') {
|
|
121
|
+
msgs.push({ role: 'tool', tool_call_id: tc.id, content: 'User denied this action.' });
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
this.stream?.emit({ type: 'loop:tool_call', data: { tool: tc.name, args: tc.arguments } });
|
|
127
|
+
this.onToolCall?.(tc.name, tc.arguments);
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
const execute = () => tool.execute(tc.arguments);
|
|
131
|
+
const toolResult = this.retry ? await this.retry.call(execute) : await execute();
|
|
132
|
+
const content = typeof toolResult === 'string' ? toolResult : JSON.stringify(toolResult);
|
|
133
|
+
msgs.push({ role: 'tool', tool_call_id: tc.id, content });
|
|
134
|
+
this.stream?.emit({ type: 'loop:tool_result', data: { tool: tc.name, result: content } });
|
|
135
|
+
} catch (err) {
|
|
136
|
+
const errMsg = `[Loop] Tool error: ${err.message}`;
|
|
137
|
+
msgs.push({ role: 'tool', tool_call_id: tc.id, content: errMsg });
|
|
138
|
+
this.stream?.emit({ type: 'loop:tool_result', data: { tool: tc.name, error: errMsg } });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// maxRounds exceeded
|
|
144
|
+
const warning = `[Loop] ended after ${this.maxRounds} rounds without final response`;
|
|
145
|
+
this.stream?.emit({ type: 'loop:done', data: { text: '', warning } });
|
|
146
|
+
return { text: '', toolCalls: [], usage: lastUsage, error: warning };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async chat(text, tools = [], options = {}) {
|
|
150
|
+
this._history.push({ role: 'user', content: text });
|
|
151
|
+
const result = await this.run(this._history, tools, options);
|
|
152
|
+
if (result.text) {
|
|
153
|
+
this._history.push({ role: 'assistant', content: result.text });
|
|
154
|
+
}
|
|
155
|
+
return result;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
stop() {
|
|
159
|
+
this._stopped = true;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
module.exports = { Loop };
|
package/src/memory.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Persistence + search across turns and sessions.
|
|
5
|
+
* Thin wrapper that delegates to a swappable store.
|
|
6
|
+
*
|
|
7
|
+
* Interface:
|
|
8
|
+
* store(content, metadata) → id
|
|
9
|
+
* search(query, options) → [{ id, content, metadata, score }]
|
|
10
|
+
* get(id) → { content, metadata }
|
|
11
|
+
* delete(id) → void
|
|
12
|
+
*
|
|
13
|
+
* Stores (swappable):
|
|
14
|
+
* SQLite FTS5 — store-sqlite.js (peer dep: better-sqlite3)
|
|
15
|
+
* JSON file — store-jsonfile.js (zero deps)
|
|
16
|
+
* Bring your own: implement { store, search, get, delete }
|
|
17
|
+
*/
|
|
18
|
+
class Memory {
|
|
19
|
+
/**
|
|
20
|
+
* @param {object} options
|
|
21
|
+
* @param {object} options.store - Store backend (must implement store/search/get/delete).
|
|
22
|
+
* @throws {Error} `[Memory] requires options.store` — when options.store is missing.
|
|
23
|
+
*/
|
|
24
|
+
constructor(options = {}) {
|
|
25
|
+
if (!options.store) throw new Error('[Memory] requires options.store');
|
|
26
|
+
this._store = options.store;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
store(content, metadata = {}) {
|
|
30
|
+
return this._store.store(content, metadata);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
search(query, options = {}) {
|
|
34
|
+
return this._store.search(query, options);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
get(id) {
|
|
38
|
+
return this._store.get(id);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
delete(id) {
|
|
42
|
+
return this._store.delete(id);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = { Memory };
|