bare-agent 0.12.2 → 0.13.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/README.md +4 -2
- package/bareagent.context.md +29 -4
- package/bin/cli.js +54 -2
- package/examples/README.md +1 -0
- package/examples/litectx-as-store.mjs +78 -0
- package/examples/litectx-mcp-child.mjs +57 -0
- package/index.d.ts +4 -1
- package/index.js +4 -0
- package/package.json +3 -2
- package/src/context-units.d.ts +44 -0
- package/src/context-units.js +225 -0
- package/src/loop.d.ts +11 -0
- package/src/loop.js +31 -1
- package/src/tools.d.ts +2 -1
- package/src/tools.js +2 -0
- package/tools/litectx-mcp.d.ts +28 -0
- package/tools/litectx-mcp.js +65 -0
package/README.md
CHANGED
|
@@ -66,13 +66,13 @@ Every piece works alone — take what you need, ignore the rest.
|
|
|
66
66
|
|
|
67
67
|
| Component | What it does |
|
|
68
68
|
|---|---|
|
|
69
|
-
| **Loop** | Think → act → observe → repeat. Calls any LLM, executes your tools, loops until done. Returns estimated USD cost per run. Governance via `Loop({ policy })` — wire bareguard's `Gate` through `wireGate(gate)` and every tool call (native, MCP, browsing, mobile) traverses one chokepoint with per-caller `ctx` routing. Bareguard owns the audit log, budget caps, and halt decisions; Loop respects the verdict. `onError` + `loop:error` surface every silent-ish failure (callback throw, Checkpoint timeout) |
|
|
69
|
+
| **Loop** | Think → act → observe → repeat. Calls any LLM, executes your tools, loops until done. Returns estimated USD cost per run. Governance via `Loop({ policy })` — wire bareguard's `Gate` through `wireGate(gate)` and every tool call (native, MCP, browsing, mobile) traverses one chokepoint with per-caller `ctx` routing. Bareguard owns the audit log, budget caps, and halt decisions; Loop respects the verdict. Context engineering via `Loop({ assemble })` — a per-round `assemble(msgs, ctx)` chokepoint to recall/compress/trim the window sent to the model (the seam litectx plugs into); returns a view, the canonical transcript stays intact, fail-open. The exported `unitAssembler`/`toUnits`/`fromUnits` adapter lets a consumer work over a neutral unit `{id, role, content, kind, pinned, atomic, tokensApprox}` — bareagent owns the grammar (atomic tool-pair bundling, pinned system/task, a pairing seatbelt), the consumer owns content + relevance. `onError` + `loop:error` surface every silent-ish failure (callback throw, Checkpoint timeout) |
|
|
70
70
|
| **Planner** | Break a goal into a step DAG via LLM. Built-in caching (`cacheTTL`) |
|
|
71
71
|
| **runPlan** | Execute steps in parallel waves. Dependency-aware, failure propagation, per-step retry |
|
|
72
72
|
| **Retry** | Exponential/linear backoff with jitter. Respects `err.retryable` |
|
|
73
73
|
| **CircuitBreaker** | Fail fast after N errors. Auto-recovers after cooldown. Per-key isolation |
|
|
74
74
|
| **Fallback** | Try providers in order — if one is down, next one picks up. Transparent to Loop |
|
|
75
|
-
| **Memory** | Persist and search context
|
|
75
|
+
| **Memory** | Persist and search context across turns/sessions through a swappable `Store`. Zero-dep JSON file by default, or mount [litectx](https://npmjs.com/package/litectx) for ranked, graph-aware recall in one line — the host code never changes ([example](examples/litectx-as-store.mjs)). A minimal `SQLite` FTS5 store also ships, though litectx supersedes it for SQLite-backed memory |
|
|
76
76
|
| **StateMachine** | Task lifecycle tracking with event hooks. `pending → running → done / failed / waiting / cancelled` |
|
|
77
77
|
| **Checkpoint** | Human approval gate. You provide the transport — terminal, Telegram, Slack, whatever |
|
|
78
78
|
| **Scheduler** | Cron (`0 9 * * 1-5`) or relative (`2h`, `30m`). Persisted jobs survive restarts |
|
|
@@ -182,6 +182,8 @@ Runnable scripts in [`examples/`](examples/) — each is self-contained and the
|
|
|
182
182
|
| [`orchestrator/`](examples/orchestrator/) | Multi-agent dispatch via `spawn`. Three configs, one system prompt — no orchestrator class, no role types. Roles are JSON files. |
|
|
183
183
|
| [`wake.sh`](examples/wake.sh) + [`wake.md`](examples/wake.md) | Reference cron + jq script for firing deferred actions. The runtime half of `createDeferTool` — bareagent emits, `wake.sh` fires. |
|
|
184
184
|
| [`replay-job.js`](examples/replay-job.js) | Supervised replay POC: record a browser task once with the LLM driving, then replay against fresh snapshots with the LLM as locator-only. Falls back to full reasoning when the locator misses, and patches the trace. |
|
|
185
|
+
| [`litectx-as-store.mjs`](examples/litectx-as-store.mjs) | Mount [litectx](https://npmjs.com/package/litectx) as the `Memory` `Store` — one-line swap from `JsonFileStore` to ranked, graph-aware recall; the host code never changes (RT-3). |
|
|
186
|
+
| [`litectx-mcp-child.mjs`](examples/litectx-mcp-child.mjs) | Give a spawned child agent litectx's reasoning verbs as MCP tools, read-only on its own db, via `liteCtxMcpBridgeConfig` + `cfg.mcp` (RT-4). |
|
|
185
187
|
|
|
186
188
|
---
|
|
187
189
|
|
package/bareagent.context.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# bareagent — Integration Guide
|
|
2
2
|
|
|
3
3
|
> For AI assistants and developers wiring bareagent into a project.
|
|
4
|
-
> v0.
|
|
4
|
+
> v0.13.0 | Node.js >= 18 | one required dep (`bareguard ^0.4.2`) | Apache 2.0
|
|
5
5
|
>
|
|
6
6
|
> Full human guide with composition examples, design philosophy, and recipes: [Usage Guide](docs/02-features/usage-guide.md)
|
|
7
7
|
|
|
@@ -14,12 +14,12 @@ npm install bare-agent
|
|
|
14
14
|
```
|
|
15
15
|
|
|
16
16
|
Eight entry points:
|
|
17
|
-
- `require('bare-agent')` — Loop, Planner, StateMachine, Scheduler, Checkpoint, Memory, Stream, Retry, runPlan, CircuitBreaker, wireGate, defaultActionTranslator, BareAgentError, ProviderError, ToolError, TimeoutError, ValidationError, CircuitOpenError, **HaltError**
|
|
17
|
+
- `require('bare-agent')` — Loop, Planner, StateMachine, Scheduler, Checkpoint, Memory, Stream, Retry, runPlan, CircuitBreaker, wireGate, defaultActionTranslator, **toUnits, fromUnits, unitAssembler** (the `assemble` context-units adapter, v0.13+), BareAgentError, ProviderError, ToolError, TimeoutError, ValidationError, CircuitOpenError, **HaltError**
|
|
18
18
|
- `require('bare-agent/errors')` — same error classes via a stable subpath (v0.10.1+) for adopters who want to import only the error surface
|
|
19
19
|
- `require('bare-agent/providers')` — OpenAI, Anthropic, Ollama, CLIPipe, Fallback (the canonical short names; `*Provider` aliases — `OpenAIProvider`, `AnthropicProvider`, etc. — are also exported and match the class names, so either destructure works, v0.12.1+)
|
|
20
20
|
- `require('bare-agent/stores')` — SQLite (FTS5), JsonFile
|
|
21
21
|
- `require('bare-agent/transports')` — JsonlTransport
|
|
22
|
-
- `require('bare-agent/tools')` — createBrowsingTools, createMobileTools, createShellTools, createSpawnTool, createDeferTool, spawnChild, readDeferQueue
|
|
22
|
+
- `require('bare-agent/tools')` — createBrowsingTools, createMobileTools, createShellTools, createSpawnTool, createDeferTool, spawnChild, readDeferQueue, liteCtxMcpBridgeConfig
|
|
23
23
|
- `require('bare-agent/mcp')` — createMCPBridge (returns `tools` + `metaTools`), discoverServers, buildMetaTools
|
|
24
24
|
- `require('bare-agent/bareguard')` — wireGate (one-line bareguard Gate integration), defaultActionTranslator
|
|
25
25
|
|
|
@@ -69,6 +69,7 @@ Eight entry points:
|
|
|
69
69
|
| **Spawn a child specialist agent** | createSpawnTool + bin/cli.js --config (v0.9+) |
|
|
70
70
|
| **Defer an action for later (cron-fired)** | createDeferTool + examples/wake.sh (v0.9+) |
|
|
71
71
|
| **Expose a large MCP catalog dynamically** | createMCPBridge → bridge.metaTools (v0.9+) |
|
|
72
|
+
| **Give a child agent litectx memory (read-only, own db)** | liteCtxMcpBridgeConfig → `cfg.mcp` in a spawn child config (RT-4) |
|
|
72
73
|
|
|
73
74
|
**Most projects start with Loop + Provider.** Add components as needed.
|
|
74
75
|
|
|
@@ -549,15 +550,39 @@ All return `{ text, toolCalls, usage: { inputTokens, outputTokens } }`. CLIPipe
|
|
|
549
550
|
|
|
550
551
|
```javascript
|
|
551
552
|
// SQLite FTS5 — full-text search with BM25 ranking (requires: npm install better-sqlite3)
|
|
553
|
+
// Minimal store, kept for back-compat. litectx strictly dominates it (same better-sqlite3
|
|
554
|
+
// requirement, but adds ranked graph-aware recall) — prefer litectx for SQLite-backed memory.
|
|
552
555
|
new SQLite({ path: './memory.db' })
|
|
553
556
|
|
|
554
557
|
// JSON file — zero deps, substring search
|
|
555
558
|
new JsonFile({ path: './memory.json' })
|
|
556
559
|
|
|
560
|
+
// litectx — ranked, graph-aware recall (RT-3 mount; requires: npm install litectx)
|
|
561
|
+
// One-line swap; the host code (memory.store/search/get/delete) never changes.
|
|
562
|
+
// import { LiteCtx, liteCtxAsStore } from 'litectx';
|
|
563
|
+
// const memory = new Memory({ store: liteCtxAsStore(new LiteCtx({ dbPath: './agent.db' })) });
|
|
564
|
+
// See examples/litectx-as-store.mjs. litectx ships the adapter; bareagent owns the Store socket.
|
|
565
|
+
|
|
557
566
|
// Custom — implement { store, search, get, delete }
|
|
558
567
|
```
|
|
559
568
|
|
|
560
|
-
**JsonFile scaling:** `search()` is an O(n) substring scan (no index) and every `store()`/`delete()` rewrites the whole file. Fine for hundreds–low-thousands of entries; for larger or write-heavy memory
|
|
569
|
+
**JsonFile scaling:** `search()` is an O(n) substring scan (no index) and every `store()`/`delete()` rewrites the whole file. Fine for hundreds–low-thousands of entries; for larger or write-heavy memory mount `litectx` for ranked graph-aware recall (the minimal bundled `SQLite` FTS5 store remains for back-compat, but litectx strictly dominates it — same `better-sqlite3` requirement, richer recall). JsonFile warns once past ~10k entries.
|
|
570
|
+
|
|
571
|
+
**Two ways to use litectx, pick by who consumes it (the two are independent):**
|
|
572
|
+
- **As your `Store`** (RT-3, above) — *your* host code recalls via `memory.search/get`. One-line `liteCtxAsStore` swap.
|
|
573
|
+
- **As a child agent's MCP toolbox** (RT-4) — give a *spawned sub-agent* litectx's own reasoning verbs (`litectx_recall`, `litectx_get`, …) so the model calls them in its loop. Use `liteCtxMcpBridgeConfig` to build the curated mount and hand it to the child via `cfg.mcp`:
|
|
574
|
+
|
|
575
|
+
```js
|
|
576
|
+
const { liteCtxMcpBridgeConfig } = require('bare-agent/tools');
|
|
577
|
+
// Read-only by default: recall/get/impact/recent allowed; remember/forget denied
|
|
578
|
+
// (writable:true to opt in — writes stay in the child's OWN --root db); index/promotions always denied.
|
|
579
|
+
const mcp = liteCtxMcpBridgeConfig({ root: './child-mem' }); // own-db isolation via --root
|
|
580
|
+
// In the spawn child config (bin/cli.js --config): { provider, model, tools, mcp, gate }
|
|
581
|
+
// mcp: <the config above> → child's MCPBridge mounts litectx-mcp; tools join BEFORE gating
|
|
582
|
+
// cfg.mcp also accepts a directory-confined { bridgePath } pointing at a .mcp-bridge.json on disk.
|
|
583
|
+
```
|
|
584
|
+
|
|
585
|
+
Requires litectx's `litectx-mcp` binary on PATH. Isolation is **physical** (each child a distinct `--root` db) — promotion to the parent is an explicit parent-side `recall`→`remember`, never automatic. See `examples/litectx-mcp-child.mjs`. bareagent imports nothing from litectx; the helper is pure config curation.
|
|
561
586
|
|
|
562
587
|
## Tool format
|
|
563
588
|
|
package/bin/cli.js
CHANGED
|
@@ -66,11 +66,54 @@ async function runConfigMode(cfgPath) {
|
|
|
66
66
|
const stream = new Stream({ transport: new JsonlTransport() });
|
|
67
67
|
|
|
68
68
|
// Provider
|
|
69
|
-
const provider = createProvider(cfg.provider || 'openai', cfg.model);
|
|
69
|
+
const provider = createProvider(cfg.provider || 'openai', cfg.model, { command: cfg.command, args: cfg.args });
|
|
70
70
|
|
|
71
71
|
// Tools — registry resolved by name from a curated set of built-ins.
|
|
72
72
|
const tools = await resolveTools(cfg.tools || [], { stream });
|
|
73
73
|
|
|
74
|
+
// Optional MCP mount (RT-4) — a child config can mount MCP servers (e.g. litectx-mcp, read-only on
|
|
75
|
+
// its own db) via `cfg.mcp`. Accepts an inline bridge config (`{ servers, ttl }`, as built by
|
|
76
|
+
// `liteCtxMcpBridgeConfig`) or `{ bridgePath }` pointing at one (confined to the config directory,
|
|
77
|
+
// same rule as gate.humanChannel). Mounted tools join the set BEFORE gating, so they traverse the
|
|
78
|
+
// same policy as native tools. The server `command` runs unsandboxed — same trust as `cfg.tools`.
|
|
79
|
+
/** @type {{ tools: ToolDef[], close: Function } | null} */
|
|
80
|
+
let mcpBridge = null;
|
|
81
|
+
if (cfg.mcp) {
|
|
82
|
+
const { createMCPBridge } = require('../src/mcp-bridge');
|
|
83
|
+
const os = require('node:os');
|
|
84
|
+
const cfgDir = path.resolve(path.dirname(cfgPath));
|
|
85
|
+
let bridgePath;
|
|
86
|
+
let tmpBridge = null;
|
|
87
|
+
if (cfg.mcp && typeof cfg.mcp === 'object' && cfg.mcp.servers) {
|
|
88
|
+
tmpBridge = path.join(os.tmpdir(), `bareagent-mcp-${process.pid}.json`);
|
|
89
|
+
fs.writeFileSync(tmpBridge, JSON.stringify(cfg.mcp));
|
|
90
|
+
bridgePath = tmpBridge;
|
|
91
|
+
} else if (cfg.mcp && typeof cfg.mcp.bridgePath === 'string') {
|
|
92
|
+
const p = path.resolve(cfgDir, cfg.mcp.bridgePath);
|
|
93
|
+
if (p !== cfgDir && !p.startsWith(cfgDir + path.sep)) {
|
|
94
|
+
process.stderr.write(`[cli] cfg.mcp.bridgePath must resolve inside the config directory (${cfgDir}); refusing ${p}\n`);
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
bridgePath = p;
|
|
98
|
+
} else {
|
|
99
|
+
process.stderr.write('[cli] cfg.mcp must be an inline bridge config ({ servers }) or { bridgePath }\n');
|
|
100
|
+
process.exit(1);
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
mcpBridge = await createMCPBridge({
|
|
104
|
+
bridgePath,
|
|
105
|
+
servers: cfg.mcp.servers ? Object.keys(cfg.mcp.servers) : undefined,
|
|
106
|
+
timeout: cfg.mcp.timeout || 15000,
|
|
107
|
+
});
|
|
108
|
+
tools.push(...mcpBridge.tools);
|
|
109
|
+
} catch (err) {
|
|
110
|
+
process.stderr.write(`[cli] failed to mount MCP (cfg.mcp): ${err.message}\n`);
|
|
111
|
+
process.exit(1);
|
|
112
|
+
} finally {
|
|
113
|
+
if (tmpBridge) { try { fs.unlinkSync(tmpBridge); } catch { /* best-effort */ } }
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
74
117
|
// Bareguard Gate (optional but strongly recommended for spawn children).
|
|
75
118
|
// Fail-closed: if the config asks for a gate but wiring fails, exit non-zero
|
|
76
119
|
// rather than run an ungoverned child agent.
|
|
@@ -163,6 +206,7 @@ async function runConfigMode(cfgPath) {
|
|
|
163
206
|
});
|
|
164
207
|
|
|
165
208
|
await loop.run([initialMessage], gatedTools);
|
|
209
|
+
if (mcpBridge) await mcpBridge.close();
|
|
166
210
|
// Stream's loop:done event has already been emitted; exit clean.
|
|
167
211
|
process.exit(0);
|
|
168
212
|
}
|
|
@@ -299,7 +343,15 @@ function runStdioMode() {
|
|
|
299
343
|
* @param {string} [model]
|
|
300
344
|
* @returns {Provider}
|
|
301
345
|
*/
|
|
302
|
-
function createProvider(name, model) {
|
|
346
|
+
function createProvider(name, model, opts = {}) {
|
|
347
|
+
if (name === 'clipipe') {
|
|
348
|
+
const { CLIPipeProvider } = require('../src/provider-clipipe');
|
|
349
|
+
if (!opts.command) {
|
|
350
|
+
process.stderr.write('[cli] provider "clipipe" requires a `command` in the config (or --command).\n');
|
|
351
|
+
process.exit(1);
|
|
352
|
+
}
|
|
353
|
+
return new CLIPipeProvider({ command: opts.command, args: opts.args || [], ...(model && { model }) });
|
|
354
|
+
}
|
|
303
355
|
if (name === 'openai') {
|
|
304
356
|
const { OpenAIProvider } = require('../src/provider-openai');
|
|
305
357
|
return new OpenAIProvider({
|
package/examples/README.md
CHANGED
|
@@ -10,5 +10,6 @@ Runnable reference scripts for bare-agent. Each is self-contained — the top-of
|
|
|
10
10
|
| [`orchestrator/`](orchestrator/) | Multi-agent dispatch via `spawn`. Three configs, one system prompt — no orchestrator class, no role types. Roles are JSON files. See its [README](orchestrator/README.md). |
|
|
11
11
|
| [`wake.sh`](wake.sh) + [`wake.md`](wake.md) | Reference cron + jq script for firing deferred actions. The runtime half of `createDeferTool` — bareagent emits, `wake.sh` fires. |
|
|
12
12
|
| [`replay-job.js`](replay-job.js) | Supervised replay POC: record a browser task once with the LLM driving, then replay against fresh snapshots with the LLM as locator-only. Falls back to full reasoning when the locator misses, and patches the trace. |
|
|
13
|
+
| [`litectx-as-store.mjs`](litectx-as-store.mjs) | RT-3 Store mount: swap the zero-dep `JsonFileStore` for litectx's ranked, graph-aware recall in one line — the host code never changes. Runs the JsonFileStore half always; runs the litectx half if `litectx` is installed, else prints the one-line swap. |
|
|
13
14
|
|
|
14
15
|
For wiring recipes and API details see the [Integration Guide](../bareagent.context.md); for usage patterns and design philosophy see the [Usage Guide](../docs/02-features/usage-guide.md).
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// examples/litectx-as-store.mjs
|
|
2
|
+
//
|
|
3
|
+
// RT-3 — mount litectx as a bareagent `Memory` backend (the rich `Store`).
|
|
4
|
+
//
|
|
5
|
+
// Run: node examples/litectx-as-store.mjs
|
|
6
|
+
// (zero-dep: runs the JsonFileStore half always; runs the litectx half only if `litectx`
|
|
7
|
+
// is installed — `npm install litectx` — otherwise it prints the one-line swap and skips.)
|
|
8
|
+
//
|
|
9
|
+
// What this demonstrates:
|
|
10
|
+
// - The `Store` socket (`{ store, search, get, delete }`) is litectx's documented mount point.
|
|
11
|
+
// Swapping the zero-dep JsonFileStore for litectx's ranked, graph-aware recall is a ONE-LINE
|
|
12
|
+
// change — the host code (everything in `hostWorkflow` below) never changes.
|
|
13
|
+
// - litectx ships the adapter (`liteCtxAsStore`); bareagent ships the socket. No bareagent import
|
|
14
|
+
// in litectx, no litectx import in bareagent — the dependency direction stays one-way.
|
|
15
|
+
//
|
|
16
|
+
// The five points where the schemaless socket and litectx's typed model are reconciled (PRD §3.2),
|
|
17
|
+
// all handled INSIDE litectx's adapter — the host never sees them:
|
|
18
|
+
// #1 the adapter mints the id (`kind:uuid`); the host supplies none.
|
|
19
|
+
// #2 `search` returns content inline via `recall({ body: true })`.
|
|
20
|
+
// #3 arbitrary host metadata round-trips through a sealed `meta` passthrough (kind/by are typed).
|
|
21
|
+
// #4 an un-kinded write defaults to `kind:"fact"` (durable agent memory); `metadata.kind` overrides.
|
|
22
|
+
// #5 `search` targets one kind so scores stay comparable across hits.
|
|
23
|
+
|
|
24
|
+
import { createRequire } from 'node:module';
|
|
25
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
26
|
+
import { join } from 'node:path';
|
|
27
|
+
import { tmpdir } from 'node:os';
|
|
28
|
+
const require = createRequire(import.meta.url);
|
|
29
|
+
const { Memory } = require('bare-agent');
|
|
30
|
+
const { JsonFile: JsonFileStore } = require('bare-agent/stores');
|
|
31
|
+
|
|
32
|
+
// --- the HOST workflow: written once, runs against ANY Store. This is the code that does NOT change
|
|
33
|
+
// when you swap the backend. Note `await` works whether the store is sync (JsonFileStore) or
|
|
34
|
+
// async (litectx) — Memory delegates the return value without awaiting. ---
|
|
35
|
+
async function hostWorkflow(memory, label) {
|
|
36
|
+
const id = await memory.store(
|
|
37
|
+
'the auth service uses a token-bucket rate limiter on /login',
|
|
38
|
+
{ sessionId: 'sess-1', tag: 'architecture' }, // arbitrary metadata — must survive the round-trip
|
|
39
|
+
);
|
|
40
|
+
const hits = await memory.search('rate limiter');
|
|
41
|
+
const fetched = memory.get(id);
|
|
42
|
+
|
|
43
|
+
console.log(`\n[${label}]`);
|
|
44
|
+
console.log(` store() → id: ${id}`);
|
|
45
|
+
console.log(` search('rate limiter') → ${hits.length} hit(s); top score=${hits[0]?.score?.toFixed?.(3) ?? hits[0]?.score}`);
|
|
46
|
+
console.log(` get(id).content: ${JSON.stringify(fetched?.content)}`);
|
|
47
|
+
console.log(` get(id).metadata: ${JSON.stringify(fetched?.metadata)} ← host metadata round-tripped`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function main() {
|
|
51
|
+
// 1) Zero-dep baseline: JsonFileStore (always runs).
|
|
52
|
+
const dir = mkdtempSync(join(tmpdir(), 'litectx-as-store-'));
|
|
53
|
+
try {
|
|
54
|
+
await hostWorkflow(new Memory({ store: new JsonFileStore({ path: join(dir, 'mem.json') }) }), 'JsonFileStore (zero-dep)');
|
|
55
|
+
} finally {
|
|
56
|
+
rmSync(dir, { recursive: true, force: true });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 2) The ONE-LINE swap to litectx — identical hostWorkflow, ranked graph-aware recall.
|
|
60
|
+
let liteCtxAsStore, LiteCtx;
|
|
61
|
+
try {
|
|
62
|
+
({ LiteCtx, liteCtxAsStore } = require('litectx')); // both from the main entry (litectx 0.10+)
|
|
63
|
+
} catch {
|
|
64
|
+
console.log('\n[litectx] not installed — the swap is one line:');
|
|
65
|
+
console.log(" import { LiteCtx, liteCtxAsStore } from 'litectx';");
|
|
66
|
+
console.log(' const lc = new LiteCtx({ dbPath: \'./agent.db\' }); await lc.ready();');
|
|
67
|
+
console.log(' const memory = new Memory({ store: liteCtxAsStore(lc) }); // ← only this line changes');
|
|
68
|
+
console.log('\n Install it (`npm install litectx`) to run the litectx half of this example.');
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const lc = new LiteCtx({ dbPath: join(tmpdir(), `litectx-as-store-${process.pid}.db`) });
|
|
73
|
+
if (typeof lc.ready === 'function') await lc.ready();
|
|
74
|
+
await hostWorkflow(new Memory({ store: liteCtxAsStore(lc) }), 'litectx (ranked, graph-aware)');
|
|
75
|
+
if (typeof lc.close === 'function') lc.close();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
main().catch((err) => { console.error(err); process.exit(1); });
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// examples/litectx-mcp-child.mjs
|
|
2
|
+
//
|
|
3
|
+
// RT-4 — give a child/sub-agent litectx memory, READ-ONLY, on its own db (own-db isolation).
|
|
4
|
+
//
|
|
5
|
+
// Run: node examples/litectx-mcp-child.mjs [--root <indexed-litectx-root>]
|
|
6
|
+
// Prints the curated .mcp-bridge.json always; launches the real mount if `litectx-mcp` is on
|
|
7
|
+
// PATH (`npm install -g litectx`), otherwise prints the one-line recipe and exits 0.
|
|
8
|
+
//
|
|
9
|
+
// What this demonstrates:
|
|
10
|
+
// - `liteCtxMcpBridgeConfig({ root })` builds the curated bridge config: the read-only default
|
|
11
|
+
// (recall/get/impact/recent allow; remember/forget/index/promotions deny) so a child can reason
|
|
12
|
+
// over memory but can't mutate durable shared state. Flip with `{ writable: true }` to opt a child
|
|
13
|
+
// into writes — which still land in ITS OWN db, never the parent's.
|
|
14
|
+
// - `createMCPBridge` launches `litectx-mcp --root <child-db>` over stdio and exposes only the
|
|
15
|
+
// allowed verbs as bareagent tools. Pass `bridge.tools` to a child Loop.
|
|
16
|
+
// - Isolation is the child's own `--root`, not a shared store — which is what keeps RT-5's scope
|
|
17
|
+
// column deferred. Promotion of a child-learned fact to the parent is an explicit, parent-side
|
|
18
|
+
// `recall`(child db) → `remember`(parent db); never automatic.
|
|
19
|
+
|
|
20
|
+
import { createRequire } from 'node:module';
|
|
21
|
+
const require = createRequire(import.meta.url);
|
|
22
|
+
const { createMCPBridge } = require('../src/mcp-bridge');
|
|
23
|
+
const { liteCtxMcpBridgeConfig } = require('bare-agent/tools');
|
|
24
|
+
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
import { tmpdir } from 'node:os';
|
|
27
|
+
|
|
28
|
+
const rootFlag = process.argv.indexOf('--root');
|
|
29
|
+
const childRoot = rootFlag !== -1 ? process.argv[rootFlag + 1] : process.cwd();
|
|
30
|
+
|
|
31
|
+
// 1) Build the curated config — the artifact a parent drops in to compose the child's toolbox.
|
|
32
|
+
const cfg = liteCtxMcpBridgeConfig({ root: childRoot });
|
|
33
|
+
console.log('Curated .mcp-bridge.json (read-only litectx mount):');
|
|
34
|
+
console.log(JSON.stringify(cfg, null, 2));
|
|
35
|
+
console.log('\n allow: recall, get, impact, recent deny: remember, forget, index, promotions');
|
|
36
|
+
console.log(' (writable:true opts into remember/forget — still child-db-local)\n');
|
|
37
|
+
|
|
38
|
+
// 2) Attempt the real mount. Connects iff `litectx-mcp` is on PATH and the root is an indexed litectx db.
|
|
39
|
+
const dir = mkdtempSync(join(tmpdir(), 'litectx-mcp-child-'));
|
|
40
|
+
const bridgePath = join(dir, '.mcp-bridge.json');
|
|
41
|
+
writeFileSync(bridgePath, JSON.stringify(cfg));
|
|
42
|
+
try {
|
|
43
|
+
const bridge = await createMCPBridge({ bridgePath, servers: ['litectx'], timeout: 8000 });
|
|
44
|
+
if (bridge.servers.includes('litectx')) {
|
|
45
|
+
console.log(`[mounted] child tools: ${bridge.tools.map((t) => t.name).join(', ')}`);
|
|
46
|
+
console.log(`[mounted] withheld: ${bridge.denied.map((d) => d.tool).join(', ')}`);
|
|
47
|
+
// hand bridge.tools to a child Loop here: new Loop({ provider, ... }).run(msgs, bridge.tools)
|
|
48
|
+
await bridge.close();
|
|
49
|
+
} else {
|
|
50
|
+
console.log('[litectx-mcp not on PATH] the mount above is the recipe. To run it live:');
|
|
51
|
+
console.log(' npm install -g litectx # provides the `litectx-mcp` command');
|
|
52
|
+
console.log(` litectx-mcp --root ${childRoot} # (index the root first; see litectx docs)`);
|
|
53
|
+
console.log(' then re-run this example. The parent code never changes.');
|
|
54
|
+
}
|
|
55
|
+
} finally {
|
|
56
|
+
rmSync(dir, { recursive: true, force: true });
|
|
57
|
+
}
|
package/index.d.ts
CHANGED
|
@@ -10,6 +10,9 @@ import { runPlan } from "./src/run-plan";
|
|
|
10
10
|
import { CircuitBreaker } from "./src/circuit-breaker";
|
|
11
11
|
import { wireGate } from "./src/bareguard-adapter";
|
|
12
12
|
import { defaultActionTranslator } from "./src/bareguard-adapter";
|
|
13
|
+
import { toUnits } from "./src/context-units";
|
|
14
|
+
import { fromUnits } from "./src/context-units";
|
|
15
|
+
import { unitAssembler } from "./src/context-units";
|
|
13
16
|
import { BareAgentError } from "./src/errors";
|
|
14
17
|
import { ProviderError } from "./src/errors";
|
|
15
18
|
import { ToolError } from "./src/errors";
|
|
@@ -17,4 +20,4 @@ import { TimeoutError } from "./src/errors";
|
|
|
17
20
|
import { ValidationError } from "./src/errors";
|
|
18
21
|
import { CircuitOpenError } from "./src/errors";
|
|
19
22
|
import { HaltError } from "./src/errors";
|
|
20
|
-
export { Loop, Planner, StateMachine, Scheduler, Checkpoint, Memory, Stream, Retry, runPlan, CircuitBreaker, wireGate, defaultActionTranslator, BareAgentError, ProviderError, ToolError, TimeoutError, ValidationError, CircuitOpenError, HaltError };
|
|
23
|
+
export { Loop, Planner, StateMachine, Scheduler, Checkpoint, Memory, Stream, Retry, runPlan, CircuitBreaker, wireGate, defaultActionTranslator, toUnits, fromUnits, unitAssembler, BareAgentError, ProviderError, ToolError, TimeoutError, ValidationError, CircuitOpenError, HaltError };
|
package/index.js
CHANGED
|
@@ -11,6 +11,7 @@ const { Retry } = require('./src/retry');
|
|
|
11
11
|
const { runPlan } = require('./src/run-plan');
|
|
12
12
|
const { CircuitBreaker } = require('./src/circuit-breaker');
|
|
13
13
|
const { wireGate, defaultActionTranslator } = require('./src/bareguard-adapter');
|
|
14
|
+
const { toUnits, fromUnits, unitAssembler } = require('./src/context-units');
|
|
14
15
|
const {
|
|
15
16
|
BareAgentError,
|
|
16
17
|
ProviderError,
|
|
@@ -34,6 +35,9 @@ module.exports = {
|
|
|
34
35
|
CircuitBreaker,
|
|
35
36
|
wireGate,
|
|
36
37
|
defaultActionTranslator,
|
|
38
|
+
toUnits,
|
|
39
|
+
fromUnits,
|
|
40
|
+
unitAssembler,
|
|
37
41
|
BareAgentError,
|
|
38
42
|
ProviderError,
|
|
39
43
|
ToolError,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bare-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"files": [
|
|
5
5
|
"index.js",
|
|
6
6
|
"index.d.ts",
|
|
@@ -91,7 +91,7 @@
|
|
|
91
91
|
}
|
|
92
92
|
},
|
|
93
93
|
"scripts": {
|
|
94
|
-
"test": "node --test
|
|
94
|
+
"test": "node --test test/**/*.test.js",
|
|
95
95
|
"typecheck": "tsc --noEmit",
|
|
96
96
|
"prebuild:types": "node scripts/clean-types.js",
|
|
97
97
|
"build:types": "tsc",
|
|
@@ -99,6 +99,7 @@
|
|
|
99
99
|
},
|
|
100
100
|
"devDependencies": {
|
|
101
101
|
"@types/node": "^22.19.19",
|
|
102
|
+
"litectx": "^0.11.0",
|
|
102
103
|
"typescript": "^5.7.0"
|
|
103
104
|
}
|
|
104
105
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* msgs → neutral units. Bundles each assistant-tool-call message with the contiguous tool result(s)
|
|
3
|
+
* that answer its ids into ONE atomic unit (so pairing can never be split). system + first user turn
|
|
4
|
+
* are pinned.
|
|
5
|
+
* @param {Array<Record<string, any>>} msgs
|
|
6
|
+
* @returns {Array<Record<string, any>>}
|
|
7
|
+
*/
|
|
8
|
+
export function toUnits(msgs: Array<Record<string, any>>): Array<Record<string, any>>;
|
|
9
|
+
/**
|
|
10
|
+
* units → msgs. Honors drop (absent units), reorder (order of the returned array), recall-inject
|
|
11
|
+
* (units with no backing → one synthesised message), and COMPRESS (a unit whose `content` was rewritten
|
|
12
|
+
* is reconstructed from the new content). Atomic units keep their assistant tool-call message verbatim
|
|
13
|
+
* so pairing holds; a content rewrite lands on the tool RESULT. A multi-result atomic bundle whose
|
|
14
|
+
* content was rewritten is kept VERBATIM — a flat string can't be faithfully split back into N
|
|
15
|
+
* results, and splitting is grammar (bareagent's), not litectx's to attempt. This isn't a special
|
|
16
|
+
* case: litectx's compress() is a pure text→text render that returns verbatim when handed no single
|
|
17
|
+
* parseable format (compress.js — "never returns less than the body losslessly"), so a flattened
|
|
18
|
+
* multi-result unit round-trips unchanged on both sides. RATIFIED by litectx (2026-06-12). The pairing
|
|
19
|
+
* seatbelt is the final guard.
|
|
20
|
+
* @param {Array<Record<string, any>>} units
|
|
21
|
+
* @returns {Array<Record<string, any>>}
|
|
22
|
+
*/
|
|
23
|
+
export function fromUnits(units: Array<Record<string, any>>): Array<Record<string, any>>;
|
|
24
|
+
/**
|
|
25
|
+
* Wrap litectx's `assemble(units, ctx)` verb into the Loop's msgs-level `assemble(msgs, ctx)` seam.
|
|
26
|
+
* litectx ships the **`AssembleResult` envelope** `{ units, dropped, tokens }` (CE-PRD §8.2: `dropped[]`
|
|
27
|
+
* is load-bearing — it ships in the same slice, never silently truncated). This wrapper accepts that
|
|
28
|
+
* envelope (uses `.units`) OR a bare `units` array (a simpler consumer). `dropped`/`tokens` are litectx's
|
|
29
|
+
* accounting; the Loop's seam is msgs-in/msgs-out, so they're not threaded onward here (the canonical
|
|
30
|
+
* transcript already holds every dropped unit by id — restorable on demand).
|
|
31
|
+
* Fail-OPEN at this layer too: any other return shape → the original msgs are sent unchanged. A thrown
|
|
32
|
+
* error (incl. HaltError) is left to the Loop's own fail-open / HaltError handling — not swallowed here.
|
|
33
|
+
* @param {(units: Array<Record<string, any>>, ctx: any) => (any | Promise<any>)} assembleUnits
|
|
34
|
+
* @returns {(msgs: Array<Record<string, any>>, ctx: any) => Promise<Array<Record<string, any>>>}
|
|
35
|
+
*/
|
|
36
|
+
export function unitAssembler(assembleUnits: (units: Array<Record<string, any>>, ctx: any) => (any | Promise<any>)): (msgs: Array<Record<string, any>>, ctx: any) => Promise<Array<Record<string, any>>>;
|
|
37
|
+
/** chars/4 token estimate over a list of messages (matches poc2 / the Loop's own heuristic). */
|
|
38
|
+
export function approxTokens(msgs: any): number;
|
|
39
|
+
/**
|
|
40
|
+
* Drop any tool-result whose tool_call_id has no open assistant tool-call before it, and any assistant
|
|
41
|
+
* tool-call message left with zero surviving results. The final grammar guard: even if litectx hands
|
|
42
|
+
* back something that would orphan a pair, the wire is always valid. Returns a fresh array.
|
|
43
|
+
*/
|
|
44
|
+
export function pairingSeatbelt(msgs: any): any[];
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// RT-1 — the msgs⇄units adapter for the `assemble` context-assembly seam (src/loop.js).
|
|
4
|
+
//
|
|
5
|
+
// Division of labour (litectx CE-PRD §8.2, line 321 — the frozen contract):
|
|
6
|
+
// - bareagent owns GRAMMAR: msgs→units, units→msgs, atomic bundling of a tool-call + its result(s),
|
|
7
|
+
// `pinned` flags, the pairing seatbelt, and fail-open. Broken tool-pairing and a dropped system
|
|
8
|
+
// prompt are UNREPRESENTABLE here, by construction — not by trusting the consumer.
|
|
9
|
+
// - litectx owns CONTENT + RELEVANCE: a single verb `assemble(units, ctx) → { units, dropped, tokens }`
|
|
10
|
+
// (the AssembleResult envelope — `dropped[]` is load-bearing, ships in the same slice, never silent)
|
|
11
|
+
// that does SELECT (keep / drop / reorder / recall-inject) + COMPRESS (rewrite a unit's `content`) +
|
|
12
|
+
// fit-to-`ctx.budget`, best-effort. It never learns the message grammar. `unitAssembler` unwraps `.units`.
|
|
13
|
+
//
|
|
14
|
+
// The neutral unit (the SOCKET litectx codes against — exactly these 7 enumerable fields):
|
|
15
|
+
// { id, role, content, kind, pinned, atomic, tokensApprox }
|
|
16
|
+
// id — stable within one assemble() call; litectx references units by it.
|
|
17
|
+
// role — 'system' | 'user' | 'assistant' | 'tool' (the message grammar role).
|
|
18
|
+
// content — flat string view litectx reads to score and MAY rewrite to COMPRESS.
|
|
19
|
+
// kind — litectx's memory-node kind ('code'|'doc'|'fact'|'episode') on units litectx
|
|
20
|
+
// INJECTS from recall. Transcript-derived units carry `null` — and that is the
|
|
21
|
+
// CORRECT value, not a placeholder. `role` and `kind` are orthogonal: `role`
|
|
22
|
+
// (user/assistant/tool/system) carries conversational position = bareagent's
|
|
23
|
+
// grammar, present on every unit; `kind` carries litectx's graph-node type, present
|
|
24
|
+
// only on units litectx owns. Typing a live turn would force litectx to learn the
|
|
25
|
+
// provider's conversation structure — the exact coupling the keystone boundary
|
|
26
|
+
// forbids (PRD §1.2 / litectx CE-PRD §8.2). RATIFIED by litectx (2026-06-12): do
|
|
27
|
+
// NOT define transcript-kinds. injected = kinded, transcript = null.
|
|
28
|
+
// pinned — true ⇒ bareagent guarantees the unit is never dropped and never reordered
|
|
29
|
+
// (system prompt, first user/task turn). litectx still sees its tokensApprox so the
|
|
30
|
+
// pin counts against the budget: pin-don't-hide.
|
|
31
|
+
// atomic — group-id (string) | null. litectx keeps/drops units sharing one group-id WHOLE,
|
|
32
|
+
// never split. bareagent pre-bundles an assistant tool-call + ALL its result(s) into
|
|
33
|
+
// one unit, so each bundle is its OWN group (group-id = the unit's id); everything
|
|
34
|
+
// else is null. NOTE: string|null, not boolean — a boolean collapses every bundle
|
|
35
|
+
// under one key and litectx would fit them all-or-nothing (test/context-units.test.js (real-litectx sweep)).
|
|
36
|
+
// tokensApprox — chars/4 estimate over the unit's backing messages, for litectx's budget math.
|
|
37
|
+
//
|
|
38
|
+
// `_msgs` (the verbatim backing messages) is attached NON-ENUMERABLE: it does not appear in the 7-field
|
|
39
|
+
// view, JSON, or iteration. It is bareagent's reconstruction backing — litectx must not read it. A unit
|
|
40
|
+
// with no backing (one litectx minted via recall-inject) is synthesised into a single message on the
|
|
41
|
+
// way out.
|
|
42
|
+
|
|
43
|
+
/** chars/4 token estimate over a list of messages (matches poc2 / the Loop's own heuristic). */
|
|
44
|
+
function approxTokens(msgs) {
|
|
45
|
+
let c = 0;
|
|
46
|
+
for (const m of msgs) {
|
|
47
|
+
if (m.content != null) c += String(m.content).length;
|
|
48
|
+
if (m.tool_calls) c += JSON.stringify(m.tool_calls).length;
|
|
49
|
+
}
|
|
50
|
+
return Math.ceil(c / 4);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Flat string view of a unit's backing messages — what litectx scores on and may rewrite. */
|
|
54
|
+
function renderContent(msgs) {
|
|
55
|
+
const parts = [];
|
|
56
|
+
for (const m of msgs) {
|
|
57
|
+
if (m.content != null && String(m.content).length) parts.push(String(m.content));
|
|
58
|
+
}
|
|
59
|
+
return parts.join('\n');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let _seq = 0;
|
|
63
|
+
/** Attach the verbatim backing + original-content fingerprint as non-enumerable carry. */
|
|
64
|
+
function withBacking(unit, msgs) {
|
|
65
|
+
Object.defineProperty(unit, '_msgs', { value: msgs, enumerable: false, writable: true });
|
|
66
|
+
Object.defineProperty(unit, '_origContent', { value: unit.content, enumerable: false, writable: true });
|
|
67
|
+
return unit;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* msgs → neutral units. Bundles each assistant-tool-call message with the contiguous tool result(s)
|
|
72
|
+
* that answer its ids into ONE atomic unit (so pairing can never be split). system + first user turn
|
|
73
|
+
* are pinned.
|
|
74
|
+
* @param {Array<Record<string, any>>} msgs
|
|
75
|
+
* @returns {Array<Record<string, any>>}
|
|
76
|
+
*/
|
|
77
|
+
function toUnits(msgs) {
|
|
78
|
+
const units = [];
|
|
79
|
+
let seenUser = false;
|
|
80
|
+
for (let i = 0; i < msgs.length; i++) {
|
|
81
|
+
const m = msgs[i];
|
|
82
|
+
|
|
83
|
+
if (m.role === 'system') {
|
|
84
|
+
units.push(withBacking({ id: `u${_seq++}`, role: 'system', content: renderContent([m]), kind: null, pinned: true, atomic: null, tokensApprox: approxTokens([m]) }, [m]));
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (m.role === 'user') {
|
|
88
|
+
const pinned = !seenUser; // first user turn = the task; pin it
|
|
89
|
+
seenUser = true;
|
|
90
|
+
units.push(withBacking({ id: `u${_seq++}`, role: 'user', content: renderContent([m]), kind: null, pinned, atomic: null, tokensApprox: approxTokens([m]) }, [m]));
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (m.role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length) {
|
|
94
|
+
// bundle this assistant message with the contiguous tool results answering its ids
|
|
95
|
+
const ids = new Set(m.tool_calls.map((t) => t.id));
|
|
96
|
+
const group = [m];
|
|
97
|
+
let j = i + 1;
|
|
98
|
+
while (j < msgs.length && msgs[j].role === 'tool' && ids.has(msgs[j].tool_call_id)) {
|
|
99
|
+
group.push(msgs[j]);
|
|
100
|
+
j++;
|
|
101
|
+
}
|
|
102
|
+
// `atomic` is litectx's group-id (string|null), NOT a boolean: units sharing one are kept/dropped
|
|
103
|
+
// whole. bareagent already pre-bundles a tool-call + its result(s) into ONE unit, so each bundle is
|
|
104
|
+
// its OWN group — group-id = the unit's id. (A boolean would collapse every bundle under one key,
|
|
105
|
+
// making litectx fit them all-or-nothing — see test/context-units.test.js (real-litectx sweep).)
|
|
106
|
+
const gid = `u${_seq++}`;
|
|
107
|
+
units.push(withBacking({ id: gid, role: 'assistant', content: renderContent(group.slice(1)), kind: null, pinned: false, atomic: gid, tokensApprox: approxTokens(group) }, group));
|
|
108
|
+
i = j - 1;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
// plain assistant text, or a stray tool message (handled by the seatbelt on the way out)
|
|
112
|
+
units.push(withBacking({ id: `u${_seq++}`, role: m.role, content: renderContent([m]), kind: null, pinned: false, atomic: null, tokensApprox: approxTokens([m]) }, [m]));
|
|
113
|
+
}
|
|
114
|
+
return units;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Drop any tool-result whose tool_call_id has no open assistant tool-call before it, and any assistant
|
|
119
|
+
* tool-call message left with zero surviving results. The final grammar guard: even if litectx hands
|
|
120
|
+
* back something that would orphan a pair, the wire is always valid. Returns a fresh array.
|
|
121
|
+
*/
|
|
122
|
+
function pairingSeatbelt(msgs) {
|
|
123
|
+
// pass 1: which tool_call ids actually have a result present?
|
|
124
|
+
const resultIds = new Set();
|
|
125
|
+
for (const m of msgs) if (m.role === 'tool' && m.tool_call_id != null) resultIds.add(m.tool_call_id);
|
|
126
|
+
|
|
127
|
+
const out = [];
|
|
128
|
+
const open = new Set();
|
|
129
|
+
for (const m of msgs) {
|
|
130
|
+
if (m.role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length) {
|
|
131
|
+
const surviving = m.tool_calls.filter((tc) => resultIds.has(tc.id));
|
|
132
|
+
if (!surviving.length) continue; // assistant tool-call with no results at all → drop the message
|
|
133
|
+
for (const tc of surviving) open.add(tc.id);
|
|
134
|
+
out.push(surviving.length === m.tool_calls.length ? m : { ...m, tool_calls: surviving });
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (m.role === 'tool') {
|
|
138
|
+
if (!open.has(m.tool_call_id)) continue; // orphan result → drop
|
|
139
|
+
open.delete(m.tool_call_id);
|
|
140
|
+
out.push(m);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
out.push(m);
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* units → msgs. Honors drop (absent units), reorder (order of the returned array), recall-inject
|
|
150
|
+
* (units with no backing → one synthesised message), and COMPRESS (a unit whose `content` was rewritten
|
|
151
|
+
* is reconstructed from the new content). Atomic units keep their assistant tool-call message verbatim
|
|
152
|
+
* so pairing holds; a content rewrite lands on the tool RESULT. A multi-result atomic bundle whose
|
|
153
|
+
* content was rewritten is kept VERBATIM — a flat string can't be faithfully split back into N
|
|
154
|
+
* results, and splitting is grammar (bareagent's), not litectx's to attempt. This isn't a special
|
|
155
|
+
* case: litectx's compress() is a pure text→text render that returns verbatim when handed no single
|
|
156
|
+
* parseable format (compress.js — "never returns less than the body losslessly"), so a flattened
|
|
157
|
+
* multi-result unit round-trips unchanged on both sides. RATIFIED by litectx (2026-06-12). The pairing
|
|
158
|
+
* seatbelt is the final guard.
|
|
159
|
+
* @param {Array<Record<string, any>>} units
|
|
160
|
+
* @returns {Array<Record<string, any>>}
|
|
161
|
+
*/
|
|
162
|
+
function fromUnits(units) {
|
|
163
|
+
const msgs = [];
|
|
164
|
+
for (const u of units) {
|
|
165
|
+
if (!u || typeof u !== 'object') continue;
|
|
166
|
+
const backing = u._msgs;
|
|
167
|
+
|
|
168
|
+
if (!Array.isArray(backing)) {
|
|
169
|
+
// litectx-minted (recall-inject): synthesise one message from role + content
|
|
170
|
+
msgs.push({ role: u.role || 'user', content: u.content == null ? '' : String(u.content) });
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const edited = u.content !== u._origContent;
|
|
175
|
+
if (!edited) {
|
|
176
|
+
for (const m of backing) msgs.push(m); // verbatim — preserves tool_call_id pairing exactly
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (!u.atomic) {
|
|
181
|
+
// single backing message, content rewritten
|
|
182
|
+
msgs.push({ ...backing[0], content: u.content == null ? '' : String(u.content) });
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// atomic + content rewritten: assistant message stays verbatim (pairing); rewrite lands on the
|
|
187
|
+
// single result. Multi-result bundle → keep verbatim (unsplittable).
|
|
188
|
+
const results = backing.slice(1);
|
|
189
|
+
if (results.length === 1) {
|
|
190
|
+
msgs.push(backing[0]);
|
|
191
|
+
msgs.push({ ...results[0], content: u.content == null ? '' : String(u.content) });
|
|
192
|
+
} else {
|
|
193
|
+
for (const m of backing) msgs.push(m);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return pairingSeatbelt(msgs);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Wrap litectx's `assemble(units, ctx)` verb into the Loop's msgs-level `assemble(msgs, ctx)` seam.
|
|
201
|
+
* litectx ships the **`AssembleResult` envelope** `{ units, dropped, tokens }` (CE-PRD §8.2: `dropped[]`
|
|
202
|
+
* is load-bearing — it ships in the same slice, never silently truncated). This wrapper accepts that
|
|
203
|
+
* envelope (uses `.units`) OR a bare `units` array (a simpler consumer). `dropped`/`tokens` are litectx's
|
|
204
|
+
* accounting; the Loop's seam is msgs-in/msgs-out, so they're not threaded onward here (the canonical
|
|
205
|
+
* transcript already holds every dropped unit by id — restorable on demand).
|
|
206
|
+
* Fail-OPEN at this layer too: any other return shape → the original msgs are sent unchanged. A thrown
|
|
207
|
+
* error (incl. HaltError) is left to the Loop's own fail-open / HaltError handling — not swallowed here.
|
|
208
|
+
* @param {(units: Array<Record<string, any>>, ctx: any) => (any | Promise<any>)} assembleUnits
|
|
209
|
+
* @returns {(msgs: Array<Record<string, any>>, ctx: any) => Promise<Array<Record<string, any>>>}
|
|
210
|
+
*/
|
|
211
|
+
function unitAssembler(assembleUnits) {
|
|
212
|
+
if (typeof assembleUnits !== 'function') {
|
|
213
|
+
throw new Error('[context-units] unitAssembler(fn): fn must be (units, ctx) => units');
|
|
214
|
+
}
|
|
215
|
+
return async (msgs, ctx) => {
|
|
216
|
+
const units = toUnits(msgs);
|
|
217
|
+
const out = await assembleUnits(units, ctx);
|
|
218
|
+
// litectx returns { units, dropped, tokens }; a bare array is also accepted. Anything else → fail-open.
|
|
219
|
+
const view = Array.isArray(out) ? out : (out && Array.isArray(out.units) ? out.units : null);
|
|
220
|
+
if (!view) return msgs; // fail-open: unrecognised return → full context
|
|
221
|
+
return fromUnits(view);
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
module.exports = { toUnits, fromUnits, unitAssembler, approxTokens, pairingSeatbelt };
|
package/src/loop.d.ts
CHANGED
|
@@ -20,6 +20,16 @@ export type LoopOptions = {
|
|
|
20
20
|
onError?: Function | undefined;
|
|
21
21
|
throwOnError?: boolean | undefined;
|
|
22
22
|
policy?: Function | undefined;
|
|
23
|
+
/**
|
|
24
|
+
* - async (msgs, ctx) => msgs. Context-assembly chokepoint: shape the
|
|
25
|
+
* window sent to the provider each round (e.g. a context-engineering library). Returns a VIEW — the
|
|
26
|
+
* canonical transcript is never mutated. Fail-open (a thrown error degrades to full context); a
|
|
27
|
+
* thrown HaltError propagates. `ctx` is the per-run opaque blob (`run(msgs, tools, { ctx })`), the
|
|
28
|
+
* same object forwarded to `policy`; litectx reads `ctx.task` (intent) and `ctx.budget`. The
|
|
29
|
+
* neutral-unit signature `assemble(units, ctx)` is provided by bareagent's msgs⇄units adapter
|
|
30
|
+
* (src/context-units.js), which composes over this msgs-level seam.
|
|
31
|
+
*/
|
|
32
|
+
assemble?: Function | undefined;
|
|
23
33
|
onLlmResult?: Function | undefined;
|
|
24
34
|
onToolResult?: Function | undefined;
|
|
25
35
|
/**
|
|
@@ -49,6 +59,7 @@ export class Loop {
|
|
|
49
59
|
throwOnError: boolean;
|
|
50
60
|
store: import("../types").Store | null;
|
|
51
61
|
policy: Function | null;
|
|
62
|
+
assemble: Function | null;
|
|
52
63
|
onLlmResult: Function | null;
|
|
53
64
|
onToolResult: Function | null;
|
|
54
65
|
_stopped: boolean;
|
package/src/loop.js
CHANGED
|
@@ -26,6 +26,13 @@ const { ToolError, HaltError } = require('./errors');
|
|
|
26
26
|
* @property {Function} [onError]
|
|
27
27
|
* @property {boolean} [throwOnError]
|
|
28
28
|
* @property {Function} [policy]
|
|
29
|
+
* @property {Function} [assemble] - async (msgs, ctx) => msgs. Context-assembly chokepoint: shape the
|
|
30
|
+
* window sent to the provider each round (e.g. a context-engineering library). Returns a VIEW — the
|
|
31
|
+
* canonical transcript is never mutated. Fail-open (a thrown error degrades to full context); a
|
|
32
|
+
* thrown HaltError propagates. `ctx` is the per-run opaque blob (`run(msgs, tools, { ctx })`), the
|
|
33
|
+
* same object forwarded to `policy`; litectx reads `ctx.task` (intent) and `ctx.budget`. The
|
|
34
|
+
* neutral-unit signature `assemble(units, ctx)` is provided by bareagent's msgs⇄units adapter
|
|
35
|
+
* (src/context-units.js), which composes over this msgs-level seam.
|
|
29
36
|
* @property {Function} [onLlmResult]
|
|
30
37
|
* @property {Function} [onToolResult]
|
|
31
38
|
* @property {number} [maxRounds] - Removed in v0.8; presence throws a migration error.
|
|
@@ -132,6 +139,10 @@ class Loop {
|
|
|
132
139
|
throw new Error('[Loop] options.policy must be a function (toolName, args, ctx) => true | string');
|
|
133
140
|
}
|
|
134
141
|
this.policy = options.policy || null;
|
|
142
|
+
if (options.assemble != null && typeof options.assemble !== 'function') {
|
|
143
|
+
throw new Error('[Loop] options.assemble must be a function (msgs, info) => msgs');
|
|
144
|
+
}
|
|
145
|
+
this.assemble = options.assemble || null;
|
|
135
146
|
if (options.onLlmResult != null && typeof options.onLlmResult !== 'function') {
|
|
136
147
|
throw new Error('[Loop] options.onLlmResult must be a function');
|
|
137
148
|
}
|
|
@@ -243,10 +254,29 @@ class Loop {
|
|
|
243
254
|
for (let round = 0; round < HARD_ROUND_LIMIT; round++) {
|
|
244
255
|
if (this._stopped) break;
|
|
245
256
|
|
|
257
|
+
// RT-1: context-assembly chokepoint. Let a caller (e.g. a context-engineering library) shape
|
|
258
|
+
// the window sent to the provider this round. Returns a VIEW — the canonical `msgs` transcript
|
|
259
|
+
// is never mutated, so result.msgs stays complete and correct. Fail-OPEN: an assembly error
|
|
260
|
+
// degrades to sending full context (a context-optimizer bug must not halt the agent); a thrown
|
|
261
|
+
// HaltError is a governance exit and propagates (same contract as onLlmResult).
|
|
262
|
+
let toSend = msgs;
|
|
263
|
+
if (this.assemble) {
|
|
264
|
+
try {
|
|
265
|
+
const view = await this.assemble(msgs, ctx);
|
|
266
|
+
if (Array.isArray(view)) {
|
|
267
|
+
toSend = view;
|
|
268
|
+
this._safeEmit({ type: 'loop:assemble', data: { round, before: msgs.length, after: toSend.length } });
|
|
269
|
+
}
|
|
270
|
+
} catch (err) {
|
|
271
|
+
if (err instanceof HaltError) throw err;
|
|
272
|
+
this._reportError('assemble', err, { round });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
246
276
|
let result;
|
|
247
277
|
const llmStartedAt = Date.now();
|
|
248
278
|
try {
|
|
249
|
-
const generate = () => this.provider.generate(
|
|
279
|
+
const generate = () => this.provider.generate(toSend, tools, options);
|
|
250
280
|
result = this.retry ? await this.retry.call(generate) : await generate();
|
|
251
281
|
} catch (err) {
|
|
252
282
|
this._reportError('provider', err, { round });
|
package/src/tools.d.ts
CHANGED
|
@@ -5,4 +5,5 @@ import { createSpawnTool } from "../tools/spawn";
|
|
|
5
5
|
import { spawnChild } from "../tools/spawn";
|
|
6
6
|
import { createDeferTool } from "../tools/defer";
|
|
7
7
|
import { readQueue as readDeferQueue } from "../tools/defer";
|
|
8
|
-
|
|
8
|
+
import { liteCtxMcpBridgeConfig } from "../tools/litectx-mcp";
|
|
9
|
+
export { createBrowsingTools, createMobileTools, createShellTools, createSpawnTool, spawnChild, createDeferTool, readDeferQueue, liteCtxMcpBridgeConfig };
|
package/src/tools.js
CHANGED
|
@@ -5,6 +5,7 @@ const { createMobileTools } = require('../tools/mobile');
|
|
|
5
5
|
const { createShellTools } = require('../tools/shell');
|
|
6
6
|
const { createSpawnTool, spawnChild } = require('../tools/spawn');
|
|
7
7
|
const { createDeferTool, readQueue: readDeferQueue } = require('../tools/defer');
|
|
8
|
+
const { liteCtxMcpBridgeConfig } = require('../tools/litectx-mcp');
|
|
8
9
|
|
|
9
10
|
module.exports = {
|
|
10
11
|
createBrowsingTools,
|
|
@@ -14,4 +15,5 @@ module.exports = {
|
|
|
14
15
|
spawnChild,
|
|
15
16
|
createDeferTool,
|
|
16
17
|
readDeferQueue,
|
|
18
|
+
liteCtxMcpBridgeConfig,
|
|
17
19
|
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a curated bridge config that mounts litectx-mcp read-only on its own db.
|
|
3
|
+
* @param {object} opts
|
|
4
|
+
* @param {string} opts.root - the child's OWN litectx root/db (passed as `--root`). Isolation.
|
|
5
|
+
* @param {string} [opts.command='litectx-mcp'] - the server command (override for a fake/abs path).
|
|
6
|
+
* @param {string[]} [opts.args=[]] - extra args appended after `--root <root>` (e.g. `--no-embeddings`).
|
|
7
|
+
* @param {boolean} [opts.writable=false] - opt-in: allow remember/forget (still child-db-local).
|
|
8
|
+
* @param {string} [opts.name='litectx'] - server name (tool prefix → `<name>_recall`, …).
|
|
9
|
+
* @param {string} [opts.ttl='24h'] - bridge config TTL.
|
|
10
|
+
* @param {string} [opts.now] - ISO timestamp for `discovered` (default: now). Pre-seed fresh so
|
|
11
|
+
* `createMCPBridge` skips IDE discovery and connects straight to this curated server.
|
|
12
|
+
* @returns {import('../src/mcp-bridge').BridgeConfig}
|
|
13
|
+
*/
|
|
14
|
+
export function liteCtxMcpBridgeConfig(opts: {
|
|
15
|
+
root: string;
|
|
16
|
+
command?: string | undefined;
|
|
17
|
+
args?: string[] | undefined;
|
|
18
|
+
writable?: boolean | undefined;
|
|
19
|
+
name?: string | undefined;
|
|
20
|
+
ttl?: string | undefined;
|
|
21
|
+
now?: string | undefined;
|
|
22
|
+
}): import("../src/mcp-bridge").BridgeConfig;
|
|
23
|
+
/** Read/reason verbs a child is given by default. */
|
|
24
|
+
export const READ_VERBS: string[];
|
|
25
|
+
/** Write verbs — denied unless `writable`, then allowed (writes still land in the child's OWN db). */
|
|
26
|
+
export const WRITE_VERBS: string[];
|
|
27
|
+
/** Admin/review verbs — always denied to a child (human/hook + review flows). */
|
|
28
|
+
export const ADMIN_VERBS: string[];
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// RT-4 — mount litectx-mcp into a child agent, read-only, on its own db. A thin recipe helper: it
|
|
4
|
+
// builds the curated `.mcp-bridge.json` config that `createMCPBridge` consumes, so a parent composing
|
|
5
|
+
// a child's toolbox can't fat-finger the allow-list (e.g. accidentally allow a write verb). It encodes
|
|
6
|
+
// ONE thing — the agreed read-only default (PRD §4.2) — and knows only litectx-mcp's public verb names
|
|
7
|
+
// (the same strings a hand-written `.mcp-bridge.json` would carry). It does NOT import litectx: the
|
|
8
|
+
// dependency direction stays one-way; this is config curation, bareagent's job.
|
|
9
|
+
//
|
|
10
|
+
// Default toolbox (PRD §4.2):
|
|
11
|
+
// recall · get · impact · recent → allow (read / reason — the point of giving a child memory)
|
|
12
|
+
// remember · forget → deny (agent writes are by:"agent", suspect-until-curated;
|
|
13
|
+
// flip with { writable: true } as the explicit opt-in)
|
|
14
|
+
// index · promotions → deny (index is human/hook-driven; promotions is a review flow)
|
|
15
|
+
//
|
|
16
|
+
// Isolation is OWN-DB, not a scope column (this is what decouples RT-4 from RT-5): the child gets its
|
|
17
|
+
// own `--root` → physical isolation, zero schema change. An opted-in child's writes land in ITS db;
|
|
18
|
+
// promotion to the parent is an explicit parent-side `recall`→`remember`, never automatic.
|
|
19
|
+
|
|
20
|
+
/** Read/reason verbs a child is given by default. */
|
|
21
|
+
const READ_VERBS = ['recall', 'get', 'impact', 'recent'];
|
|
22
|
+
/** Write verbs — denied unless `writable`, then allowed (writes still land in the child's OWN db). */
|
|
23
|
+
const WRITE_VERBS = ['remember', 'forget'];
|
|
24
|
+
/** Admin/review verbs — always denied to a child (human/hook + review flows). */
|
|
25
|
+
const ADMIN_VERBS = ['index', 'promotions'];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Build a curated bridge config that mounts litectx-mcp read-only on its own db.
|
|
29
|
+
* @param {object} opts
|
|
30
|
+
* @param {string} opts.root - the child's OWN litectx root/db (passed as `--root`). Isolation.
|
|
31
|
+
* @param {string} [opts.command='litectx-mcp'] - the server command (override for a fake/abs path).
|
|
32
|
+
* @param {string[]} [opts.args=[]] - extra args appended after `--root <root>` (e.g. `--no-embeddings`).
|
|
33
|
+
* @param {boolean} [opts.writable=false] - opt-in: allow remember/forget (still child-db-local).
|
|
34
|
+
* @param {string} [opts.name='litectx'] - server name (tool prefix → `<name>_recall`, …).
|
|
35
|
+
* @param {string} [opts.ttl='24h'] - bridge config TTL.
|
|
36
|
+
* @param {string} [opts.now] - ISO timestamp for `discovered` (default: now). Pre-seed fresh so
|
|
37
|
+
* `createMCPBridge` skips IDE discovery and connects straight to this curated server.
|
|
38
|
+
* @returns {import('../src/mcp-bridge').BridgeConfig}
|
|
39
|
+
*/
|
|
40
|
+
function liteCtxMcpBridgeConfig(opts) {
|
|
41
|
+
if (!opts || typeof opts.root !== 'string' || !opts.root) {
|
|
42
|
+
throw new Error('[litectx-mcp] requires opts.root (the child\'s own db root — own-db isolation)');
|
|
43
|
+
}
|
|
44
|
+
const { root, command = 'litectx-mcp', args = [], writable = false, name = 'litectx', ttl = '24h', now } = opts;
|
|
45
|
+
|
|
46
|
+
/** @type {Record<string, string>} */
|
|
47
|
+
const tools = {};
|
|
48
|
+
for (const v of READ_VERBS) tools[v] = 'allow';
|
|
49
|
+
for (const v of WRITE_VERBS) tools[v] = writable ? 'allow' : 'deny';
|
|
50
|
+
for (const v of ADMIN_VERBS) tools[v] = 'deny';
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
discovered: now || new Date().toISOString(),
|
|
54
|
+
ttl,
|
|
55
|
+
servers: {
|
|
56
|
+
[name]: {
|
|
57
|
+
command,
|
|
58
|
+
args: ['--root', root, ...args],
|
|
59
|
+
tools,
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
module.exports = { liteCtxMcpBridgeConfig, READ_VERBS, WRITE_VERBS, ADMIN_VERBS };
|