bare-agent 0.13.1 → 0.15.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 +13 -10
- package/bareagent.context.md +3 -3
- package/index.d.ts +3 -1
- package/index.js +3 -1
- package/package.json +2 -2
- package/src/context-units.d.ts +61 -0
- package/src/context-units.js +101 -1
- package/src/loop.d.ts +21 -1
- package/src/loop.js +148 -2
package/README.md
CHANGED
|
@@ -66,7 +66,7 @@ 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. 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. The CE function reads its inputs from the per-run `ctx` — litectx's budget-fitter uses `ctx.budget` (and `ctx.task`), so you **must** populate it via `run(msgs, tools, { ctx })`: an unset `ctx.budget` means the fitter has no budget, keeps everything, and returns the window unchanged — a silent no-op, not a bug (see `examples/litectx-assemble.mjs`). `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. The CE function reads its inputs from the per-run `ctx` — litectx's budget-fitter uses `ctx.budget` (and `ctx.task`), so you **must** populate it via `run(msgs, tools, { ctx })`: an unset `ctx.budget` means the fitter has no budget, keeps everything, and returns the window unchanged — a silent no-op, not a bug (see `examples/litectx-assemble.mjs`). For summary-window compaction the Loop also lends a provider-bound `ctx.summarize(excerpt) => Promise<string>` (R-C6): the consumer owns when/what to summarize and the splice, bareagent makes the one model call (counted against the budget via `onLlmResult`, tagged `kind:'summarize'`). For an unbounded long-running agent there's the **destructive** counterpart `Loop({ trim })` (RT-2) — a per-round bound on the canonical transcript that evicts old turns *after* harvesting them; wire it with the exported `unitTrimmer({ trim, onHarvest, policy })` over litectx's `trim` verb (harvest-before-evict, fail-open; `harvestKey` gives the stable upsert id), opt-in (requires a consumer on litectx ≥ 0.16.0). `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` |
|
|
@@ -240,17 +240,20 @@ For wiring recipes and API details, see the **[Integration Guide](bareagent.cont
|
|
|
240
240
|
|
|
241
241
|
## The bare ecosystem
|
|
242
242
|
|
|
243
|
-
|
|
243
|
+
Local-first, composable agent infrastructure. Same API patterns throughout —
|
|
244
|
+
mix and match, each module works standalone.
|
|
244
245
|
|
|
245
|
-
|
|
246
|
-
|---|---|---|---|---|
|
|
247
|
-
| **Does** | Gives agents a think→act loop | Gives agents a real browser | Gives agents Android + iOS devices | Gates everything an agent does |
|
|
248
|
-
| **How** | Goal in → coordinated actions out | URL in → pruned snapshot out | Screen in → pruned snapshot out | Action in → allow / deny / human-asked out |
|
|
249
|
-
| **Replaces** | LangChain, CrewAI, AutoGen | Playwright, Selenium, Puppeteer | Appium, Espresso, XCUITest | Hand-rolled allowlists, scattered policy code |
|
|
250
|
-
| **Interfaces** | Library · CLI · subprocess | Library · CLI · MCP | Library · CLI · MCP | Library |
|
|
251
|
-
| **Solo or together** | Orchestrates the others as tools | Works standalone | Works standalone | Embedded in bareagent's loop; usable by any runner |
|
|
246
|
+
**Core** — the brain, the gate, the memory.
|
|
252
247
|
|
|
253
|
-
|
|
248
|
+
- **[bareagent](https://npmjs.com/package/bare-agent)** — the think→act→observe loop. *Goal in → coordinated actions out.* Replaces LangChain, CrewAI, AutoGen.
|
|
249
|
+
- **[bareguard](https://npmjs.com/package/bareguard)** — the single gate every action passes through. *Action in → allow / deny / ask-a-human out.* Replaces hand-rolled allowlists and scattered policy code.
|
|
250
|
+
- **[litectx](https://npmjs.com/package/litectx)** — tree-sitter code + memory graph with activation decay, plus lightweight context engineering (write · select · compress · isolate). *Query in → ranked context out.*
|
|
251
|
+
|
|
252
|
+
**Optional reach** — give the agent hands.
|
|
253
|
+
|
|
254
|
+
- **[barebrowse](https://npmjs.com/package/barebrowse)** — a real browser for agents. *URL in → pruned snapshot out.* Replaces Playwright, Selenium, Puppeteer.
|
|
255
|
+
- **[baremobile](https://npmjs.com/package/baremobile)** — Android + iOS device control. *Screen in → pruned snapshot out.* Replaces Appium, Espresso, XCUITest.
|
|
256
|
+
- **[beeperbox](https://github.com/hamr0/beeperbox)** — 50+ messaging networks via one MCP server (headless Beeper Desktop in Docker). *Chat in → unified message stream out.* Replaces Twilio, per-platform bot APIs.
|
|
254
257
|
|
|
255
258
|
**What you can build:**
|
|
256
259
|
|
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.15.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,7 +14,7 @@ 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, **toUnits, fromUnits, unitAssembler** (the `assemble` context-units adapter, v0.13+), 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+), **unitTrimmer, harvestKey** (the destructive `trim` seam adapter — RT-2 harvest-before-evict, needs a consumer on litectx ≥ 0.16.0), 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
|
|
@@ -306,7 +306,7 @@ if (result.error?.startsWith('halt:')) {
|
|
|
306
306
|
}
|
|
307
307
|
```
|
|
308
308
|
|
|
309
|
-
**Why four pieces (`policy` + `onLlmResult` + `onToolResult` + `filterTools`).** `policy` runs `gate.check` *before* every tool call. `onLlmResult` fires after every successful `provider.generate` — without it, `budget.maxCostUsd` never sees LLM cost and is silently undercounted for token-heavy / tool-light workloads (every chatbot). `onToolResult` fires after every `tool.execute` and carries the per-run `ctx` opaque blob into `gate.record` so per-principal accounting works. `filterTools` is a `gate.allows` pre-filter — denied tools are dropped from the catalog the LLM ever sees, no `gate.check` round-trip per call.
|
|
309
|
+
**Why four pieces (`policy` + `onLlmResult` + `onToolResult` + `filterTools`).** `policy` runs `gate.check` *before* every tool call. `onLlmResult` fires after every successful `provider.generate` — without it, `budget.maxCostUsd` never sees LLM cost and is silently undercounted for token-heavy / tool-light workloads (every chatbot). It also fires for the out-of-band `ctx.summarize` call (R-C6) tagged `kind:'summarize'`; main-loop rounds carry `kind:'turn'` — so summary-window tokens count against the budget too, and a consumer can tell the two apart. `onToolResult` fires after every `tool.execute` and carries the per-run `ctx` opaque blob into `gate.record` so per-principal accounting works. `filterTools` is a `gate.allows` pre-filter — denied tools are dropped from the catalog the LLM ever sees, no `gate.check` round-trip per call.
|
|
310
310
|
|
|
311
311
|
Halt-severity decisions exit the loop cleanly via a typed `HaltError` — full mechanics (sealed `msgs`, `halt:<rule>` error token, `loop:done{halted:true}` event, `throwOnError:true` interaction, `halt:unknown` coalesce) are in the **Halt decisions throw `HaltError`** paragraph below. Short version: check `result.error?.startsWith('halt:')` after the run.
|
|
312
312
|
|
package/index.d.ts
CHANGED
|
@@ -13,6 +13,8 @@ import { defaultActionTranslator } from "./src/bareguard-adapter";
|
|
|
13
13
|
import { toUnits } from "./src/context-units";
|
|
14
14
|
import { fromUnits } from "./src/context-units";
|
|
15
15
|
import { unitAssembler } from "./src/context-units";
|
|
16
|
+
import { unitTrimmer } from "./src/context-units";
|
|
17
|
+
import { harvestKey } from "./src/context-units";
|
|
16
18
|
import { BareAgentError } from "./src/errors";
|
|
17
19
|
import { ProviderError } from "./src/errors";
|
|
18
20
|
import { ToolError } from "./src/errors";
|
|
@@ -20,4 +22,4 @@ import { TimeoutError } from "./src/errors";
|
|
|
20
22
|
import { ValidationError } from "./src/errors";
|
|
21
23
|
import { CircuitOpenError } from "./src/errors";
|
|
22
24
|
import { HaltError } from "./src/errors";
|
|
23
|
-
export { Loop, Planner, StateMachine, Scheduler, Checkpoint, Memory, Stream, Retry, runPlan, CircuitBreaker, wireGate, defaultActionTranslator, toUnits, fromUnits, unitAssembler, BareAgentError, ProviderError, ToolError, TimeoutError, ValidationError, CircuitOpenError, HaltError };
|
|
25
|
+
export { Loop, Planner, StateMachine, Scheduler, Checkpoint, Memory, Stream, Retry, runPlan, CircuitBreaker, wireGate, defaultActionTranslator, toUnits, fromUnits, unitAssembler, unitTrimmer, harvestKey, BareAgentError, ProviderError, ToolError, TimeoutError, ValidationError, CircuitOpenError, HaltError };
|
package/index.js
CHANGED
|
@@ -11,7 +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
|
+
const { toUnits, fromUnits, unitAssembler, unitTrimmer, harvestKey } = require('./src/context-units');
|
|
15
15
|
const {
|
|
16
16
|
BareAgentError,
|
|
17
17
|
ProviderError,
|
|
@@ -38,6 +38,8 @@ module.exports = {
|
|
|
38
38
|
toUnits,
|
|
39
39
|
fromUnits,
|
|
40
40
|
unitAssembler,
|
|
41
|
+
unitTrimmer,
|
|
42
|
+
harvestKey,
|
|
41
43
|
BareAgentError,
|
|
42
44
|
ProviderError,
|
|
43
45
|
ToolError,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bare-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"files": [
|
|
5
5
|
"index.js",
|
|
6
6
|
"index.d.ts",
|
|
@@ -99,7 +99,7 @@
|
|
|
99
99
|
},
|
|
100
100
|
"devDependencies": {
|
|
101
101
|
"@types/node": "^22.19.19",
|
|
102
|
-
"litectx": "^0.
|
|
102
|
+
"litectx": "^0.16.0",
|
|
103
103
|
"typescript": "^5.7.0"
|
|
104
104
|
}
|
|
105
105
|
}
|
package/src/context-units.d.ts
CHANGED
|
@@ -34,6 +34,67 @@ export function fromUnits(units: Array<Record<string, any>>): Array<Record<strin
|
|
|
34
34
|
* @returns {(msgs: Array<Record<string, any>>, ctx: any) => Promise<Array<Record<string, any>>>}
|
|
35
35
|
*/
|
|
36
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
|
+
/**
|
|
38
|
+
* Wrap litectx's `trim(units, policy)` verb (R-C5) into the Loop's destructive `trim(msgs, ctx)` seam —
|
|
39
|
+
* the RT-2 harvest-before-evict interlock. Unlike {@link unitAssembler} (a non-destructive per-round VIEW),
|
|
40
|
+
* this is EVICTION: the Loop replaces its canonical transcript with the returned (smaller) msgs, so old
|
|
41
|
+
* turns are permanently dropped — which is only safe because every dropped turn is harvested FIRST.
|
|
42
|
+
*
|
|
43
|
+
* The returned function `(msgs, ctx) => keptMsgs`:
|
|
44
|
+
* 1. `toUnits(msgs)` → `trim(units, policy)` → `{ units (kept), dropped, harvest }`.
|
|
45
|
+
* 2. **Interlock — harvest BEFORE evict:** `await onHarvest({ key, content, unit })` for every harvest
|
|
46
|
+
* unit. If `onHarvest` throws (e.g. a write-gate `deny` → HaltError, or a store fault), this throws
|
|
47
|
+
* BEFORE returning the evicted view → the Loop fail-opens (no eviction that round) → nothing is lost;
|
|
48
|
+
* the next round retries and the idempotent key upserts the already-persisted ones. You cannot drop
|
|
49
|
+
* history you have not persisted.
|
|
50
|
+
* 3. `fromUnits(kept)` is the bounded transcript. Fail-OPEN: an unrecognised `trim` return shape → the
|
|
51
|
+
* original msgs unchanged (no eviction). A throw propagates (HaltError → governance halt).
|
|
52
|
+
*
|
|
53
|
+
* `.flush(msgs, ctx)` — the F2 residual harvest: `trim` only hands back EVICTED turns, so the final
|
|
54
|
+
* keepLastN window is never harvested mid-run and a clean run would diverge from an end-of-task batch.
|
|
55
|
+
* Call `.flush` on completion to harvest the surviving non-pinned turns (no eviction); the idempotent key
|
|
56
|
+
* means it never duplicates what eviction already harvested. The Loop invokes it only on **clean
|
|
57
|
+
* completion** — on a halt (e.g. bareguard `maxTurns`), `stop()`, or error exit the survivors stay intact
|
|
58
|
+
* in `result.msgs` but are NOT auto-flushed (auto-flushing a content-halt could re-trigger the deny that
|
|
59
|
+
* caused it); harvest `result.msgs` yourself on those exits if you need the final window persisted.
|
|
60
|
+
*
|
|
61
|
+
* `onHarvest` is the consumer's harvest POLICY point (litectx CE-PRD §6/R-W*: bareagent owns the trigger
|
|
62
|
+
* + what's worth keeping, litectx owns the mechanism + worklist). It is REQUIRED — the adapter structurally
|
|
63
|
+
* enforces harvest-before-evict; pass `async () => {}` to opt INTO lossy bounding.
|
|
64
|
+
*
|
|
65
|
+
* @param {{ trim?: Function, onHarvest?: Function, policy?: any }} [opts]
|
|
66
|
+
* `trim` — litectx's `(units, policy) => { units, harvest }` verb (REQUIRED). `onHarvest` —
|
|
67
|
+
* `({ key, content, unit }) => void|Promise` (REQUIRED; the harvest policy point). `policy` — litectx
|
|
68
|
+
* TrimPolicy: `{ keepLastN }` or `{ maxTokens }` (maxTokens wins). Both verbs are runtime-checked.
|
|
69
|
+
* @returns {((msgs: Array<Record<string, any>>, ctx?: any) => Promise<Array<Record<string, any>>>) & { flush: (msgs: Array<Record<string, any>>, ctx?: any) => Promise<void> }}
|
|
70
|
+
*/
|
|
71
|
+
export function unitTrimmer(opts?: {
|
|
72
|
+
trim?: Function;
|
|
73
|
+
onHarvest?: Function;
|
|
74
|
+
policy?: any;
|
|
75
|
+
}): ((msgs: Array<Record<string, any>>, ctx?: any) => Promise<Array<Record<string, any>>>) & {
|
|
76
|
+
flush: (msgs: Array<Record<string, any>>, ctx?: any) => Promise<void>;
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* Stable harvest key for a unit — the dedup id for harvest-before-evict (RT-2). MUST come from a durable
|
|
80
|
+
* property of the TURN, never from `unit.id`: `toUnits` mints ids from a module counter, so the same turn
|
|
81
|
+
* is `u1` on one call and `u3` on the next (poc/rt2-trim-interlock.mjs F1) — keying on it double-writes.
|
|
82
|
+
* Derived here from the unit's verbatim `_msgs` backing: the joined `tool_call_id`s for a tool turn (stable
|
|
83
|
+
* by construction), else a hash of `[role, content]` for a plain turn. The consumer namespaces it (e.g.
|
|
84
|
+
* `${taskId}:${key}`) and feeds it to `remember(id, …)`, which upserts → a replayed hop overwrites instead
|
|
85
|
+
* of duplicating. NOT a content search (litectx FTS can't match ids; sealed meta is unsearchable) — a
|
|
86
|
+
* deterministic key passed to the keyed write. The consumer's `taskId` prefix is the isolation boundary;
|
|
87
|
+
* treat the returned key as opaque (don't re-parse it).
|
|
88
|
+
*
|
|
89
|
+
* Two collision hardenings (grounded in poc/rt2-audit-grounding.mjs): ids are `encodeURIComponent`-escaped
|
|
90
|
+
* before the `,` join, so `['a','b']` and `['a,b']` can no longer alias the same key; and the plain-turn
|
|
91
|
+
* hash is **two near-independent streams** (FNV-1a + DJB2) → ~64-bit, so a long-running agent's harvest
|
|
92
|
+
* can't silently overwrite a turn via a 32-bit birthday collision (a single 32-bit FNV exhausted in ~5e5
|
|
93
|
+
* distinct turns). Normal provider ids (`call_…`) round-trip unchanged through the escape.
|
|
94
|
+
* @param {Record<string, any>} unit - a unit from {@link toUnits} (its `_msgs` backing is read).
|
|
95
|
+
* @returns {string}
|
|
96
|
+
*/
|
|
97
|
+
export function harvestKey(unit: Record<string, any>): string;
|
|
37
98
|
/** chars/4 token estimate over a list of messages (matches poc2 / the Loop's own heuristic). */
|
|
38
99
|
export function approxTokens(msgs: any): number;
|
|
39
100
|
/**
|
package/src/context-units.js
CHANGED
|
@@ -222,4 +222,104 @@ function unitAssembler(assembleUnits) {
|
|
|
222
222
|
};
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
-
|
|
225
|
+
/**
|
|
226
|
+
* Stable harvest key for a unit — the dedup id for harvest-before-evict (RT-2). MUST come from a durable
|
|
227
|
+
* property of the TURN, never from `unit.id`: `toUnits` mints ids from a module counter, so the same turn
|
|
228
|
+
* is `u1` on one call and `u3` on the next (poc/rt2-trim-interlock.mjs F1) — keying on it double-writes.
|
|
229
|
+
* Derived here from the unit's verbatim `_msgs` backing: the joined `tool_call_id`s for a tool turn (stable
|
|
230
|
+
* by construction), else a hash of `[role, content]` for a plain turn. The consumer namespaces it (e.g.
|
|
231
|
+
* `${taskId}:${key}`) and feeds it to `remember(id, …)`, which upserts → a replayed hop overwrites instead
|
|
232
|
+
* of duplicating. NOT a content search (litectx FTS can't match ids; sealed meta is unsearchable) — a
|
|
233
|
+
* deterministic key passed to the keyed write. The consumer's `taskId` prefix is the isolation boundary;
|
|
234
|
+
* treat the returned key as opaque (don't re-parse it).
|
|
235
|
+
*
|
|
236
|
+
* Two collision hardenings (grounded in poc/rt2-audit-grounding.mjs): ids are `encodeURIComponent`-escaped
|
|
237
|
+
* before the `,` join, so `['a','b']` and `['a,b']` can no longer alias the same key; and the plain-turn
|
|
238
|
+
* hash is **two near-independent streams** (FNV-1a + DJB2) → ~64-bit, so a long-running agent's harvest
|
|
239
|
+
* can't silently overwrite a turn via a 32-bit birthday collision (a single 32-bit FNV exhausted in ~5e5
|
|
240
|
+
* distinct turns). Normal provider ids (`call_…`) round-trip unchanged through the escape.
|
|
241
|
+
* @param {Record<string, any>} unit - a unit from {@link toUnits} (its `_msgs` backing is read).
|
|
242
|
+
* @returns {string}
|
|
243
|
+
*/
|
|
244
|
+
function harvestKey(unit) {
|
|
245
|
+
const back = (unit && unit._msgs) || [];
|
|
246
|
+
/** @type {string[]} */
|
|
247
|
+
const calls = [];
|
|
248
|
+
for (const m of back) {
|
|
249
|
+
if (m.role === 'assistant' && Array.isArray(m.tool_calls)) for (const tc of m.tool_calls) if (tc && tc.id) calls.push(tc.id);
|
|
250
|
+
}
|
|
251
|
+
// escape so a literal ',' inside an id can't alias across call shapes ([{id:'a,b'}] vs [{id:'a'},{id:'b'}]).
|
|
252
|
+
if (calls.length) return `tc:${calls.map((id) => encodeURIComponent(id)).join(',')}`;
|
|
253
|
+
// plain turn (no tool_calls): two near-independent rolling hashes over role+content (FNV-1a + DJB2),
|
|
254
|
+
// concatenated to ~64 bits → collision-resistant at agent scale. Stable across toUnits calls.
|
|
255
|
+
let h1 = 0x811c9dc5; // FNV-1a offset basis
|
|
256
|
+
let h2 = 5381; // DJB2 seed
|
|
257
|
+
const s = JSON.stringify(back.map((m) => [m.role, m.content]));
|
|
258
|
+
for (let i = 0; i < s.length; i++) {
|
|
259
|
+
const c = s.charCodeAt(i);
|
|
260
|
+
h1 ^= c; h1 = Math.imul(h1, 0x01000193);
|
|
261
|
+
h2 = (Math.imul(h2, 33) + c) | 0;
|
|
262
|
+
}
|
|
263
|
+
return `h:${(h1 >>> 0).toString(36)}${(h2 >>> 0).toString(36)}`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Wrap litectx's `trim(units, policy)` verb (R-C5) into the Loop's destructive `trim(msgs, ctx)` seam —
|
|
268
|
+
* the RT-2 harvest-before-evict interlock. Unlike {@link unitAssembler} (a non-destructive per-round VIEW),
|
|
269
|
+
* this is EVICTION: the Loop replaces its canonical transcript with the returned (smaller) msgs, so old
|
|
270
|
+
* turns are permanently dropped — which is only safe because every dropped turn is harvested FIRST.
|
|
271
|
+
*
|
|
272
|
+
* The returned function `(msgs, ctx) => keptMsgs`:
|
|
273
|
+
* 1. `toUnits(msgs)` → `trim(units, policy)` → `{ units (kept), dropped, harvest }`.
|
|
274
|
+
* 2. **Interlock — harvest BEFORE evict:** `await onHarvest({ key, content, unit })` for every harvest
|
|
275
|
+
* unit. If `onHarvest` throws (e.g. a write-gate `deny` → HaltError, or a store fault), this throws
|
|
276
|
+
* BEFORE returning the evicted view → the Loop fail-opens (no eviction that round) → nothing is lost;
|
|
277
|
+
* the next round retries and the idempotent key upserts the already-persisted ones. You cannot drop
|
|
278
|
+
* history you have not persisted.
|
|
279
|
+
* 3. `fromUnits(kept)` is the bounded transcript. Fail-OPEN: an unrecognised `trim` return shape → the
|
|
280
|
+
* original msgs unchanged (no eviction). A throw propagates (HaltError → governance halt).
|
|
281
|
+
*
|
|
282
|
+
* `.flush(msgs, ctx)` — the F2 residual harvest: `trim` only hands back EVICTED turns, so the final
|
|
283
|
+
* keepLastN window is never harvested mid-run and a clean run would diverge from an end-of-task batch.
|
|
284
|
+
* Call `.flush` on completion to harvest the surviving non-pinned turns (no eviction); the idempotent key
|
|
285
|
+
* means it never duplicates what eviction already harvested. The Loop invokes it only on **clean
|
|
286
|
+
* completion** — on a halt (e.g. bareguard `maxTurns`), `stop()`, or error exit the survivors stay intact
|
|
287
|
+
* in `result.msgs` but are NOT auto-flushed (auto-flushing a content-halt could re-trigger the deny that
|
|
288
|
+
* caused it); harvest `result.msgs` yourself on those exits if you need the final window persisted.
|
|
289
|
+
*
|
|
290
|
+
* `onHarvest` is the consumer's harvest POLICY point (litectx CE-PRD §6/R-W*: bareagent owns the trigger
|
|
291
|
+
* + what's worth keeping, litectx owns the mechanism + worklist). It is REQUIRED — the adapter structurally
|
|
292
|
+
* enforces harvest-before-evict; pass `async () => {}` to opt INTO lossy bounding.
|
|
293
|
+
*
|
|
294
|
+
* @param {{ trim?: Function, onHarvest?: Function, policy?: any }} [opts]
|
|
295
|
+
* `trim` — litectx's `(units, policy) => { units, harvest }` verb (REQUIRED). `onHarvest` —
|
|
296
|
+
* `({ key, content, unit }) => void|Promise` (REQUIRED; the harvest policy point). `policy` — litectx
|
|
297
|
+
* TrimPolicy: `{ keepLastN }` or `{ maxTokens }` (maxTokens wins). Both verbs are runtime-checked.
|
|
298
|
+
* @returns {((msgs: Array<Record<string, any>>, ctx?: any) => Promise<Array<Record<string, any>>>) & { flush: (msgs: Array<Record<string, any>>, ctx?: any) => Promise<void> }}
|
|
299
|
+
*/
|
|
300
|
+
function unitTrimmer(opts) {
|
|
301
|
+
const { trim, onHarvest, policy = {} } = opts || {};
|
|
302
|
+
if (typeof trim !== 'function') throw new Error('[context-units] unitTrimmer({ trim }): trim must be litectx\'s (units, policy) => { units, harvest } verb');
|
|
303
|
+
if (typeof onHarvest !== 'function') throw new Error('[context-units] unitTrimmer({ onHarvest }): onHarvest is required (harvest-before-evict). Pass `async () => {}` to opt into lossy bounding.');
|
|
304
|
+
|
|
305
|
+
/** @type {any} */
|
|
306
|
+
const run = async (/** @type {Array<Record<string, any>>} */ msgs /*, ctx */) => {
|
|
307
|
+
const units = toUnits(msgs);
|
|
308
|
+
const r = await trim(units, policy);
|
|
309
|
+
const kept = r && Array.isArray(r.units) ? r.units : null;
|
|
310
|
+
if (!kept) return msgs; // fail-open: unrecognised return shape → no eviction
|
|
311
|
+
const harvest = r && Array.isArray(r.harvest) ? r.harvest : [];
|
|
312
|
+
for (const u of harvest) await onHarvest({ key: harvestKey(u), content: u.content, unit: u }); // BEFORE evict
|
|
313
|
+
return fromUnits(kept);
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
// F2 residual: harvest the surviving non-pinned turns (no eviction). pinned (system + first user) are
|
|
317
|
+
// reconstructable/anchor turns, never evicted by trim, so they're excluded here for symmetry.
|
|
318
|
+
run.flush = async (/** @type {Array<Record<string, any>>} */ msgs /*, ctx */) => {
|
|
319
|
+
for (const u of toUnits(msgs)) if (!u.pinned) await onHarvest({ key: harvestKey(u), content: u.content, unit: u });
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
return run;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
module.exports = { toUnits, fromUnits, unitAssembler, unitTrimmer, harvestKey, approxTokens, pairingSeatbelt };
|
package/src/loop.d.ts
CHANGED
|
@@ -27,9 +27,28 @@ export type LoopOptions = {
|
|
|
27
27
|
* thrown HaltError propagates. `ctx` is the per-run opaque blob (`run(msgs, tools, { ctx })`), the
|
|
28
28
|
* same object forwarded to `policy`; litectx reads `ctx.task` (intent) and `ctx.budget`. The
|
|
29
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.
|
|
30
|
+
* (src/context-units.js), which composes over this msgs-level seam. When `ctx` is an object, the
|
|
31
|
+
* Loop also lends a provider-bound `ctx.summarize(excerpt, opts?) => Promise<string>` (R-C6,
|
|
32
|
+
* non-enumerable): assemble calls it to roll a summary window — bareagent makes the one model
|
|
33
|
+
* call, the consumer owns the trigger/N/splice. Its usage is forwarded to `onLlmResult` so the
|
|
34
|
+
* summary tokens count against the budget.
|
|
31
35
|
*/
|
|
32
36
|
assemble?: Function | undefined;
|
|
37
|
+
/**
|
|
38
|
+
* - async (msgs, ctx) => msgs. DESTRUCTIVE transcript-trim chokepoint (RT-2),
|
|
39
|
+
* the opposite of `assemble`: it BOUNDS the canonical transcript — the Loop replaces `msgs` with what
|
|
40
|
+
* this returns, evicting old turns AFTER they are harvested. Runs once per round before `assemble`.
|
|
41
|
+
* So eviction never drops un-persisted history, wire it via `unitTrimmer({ trim, onHarvest, policy })`
|
|
42
|
+
* (src/context-units.js), which performs the harvest-before-evict interlock over litectx's `trim` verb.
|
|
43
|
+
* An optional `.flush(msgs, ctx)` method is called on clean completion for the residual-window harvest.
|
|
44
|
+
* Fail-open (a trim fault degrades to no eviction that round); a thrown HaltError propagates.
|
|
45
|
+
*/
|
|
46
|
+
trim?: Function | undefined;
|
|
47
|
+
/**
|
|
48
|
+
* - async (event) => void after each LLM call; forwards usage to
|
|
49
|
+
* gate.record (via wireGate). `event.kind` discriminates the source: `'turn'` for a main-loop round,
|
|
50
|
+
* `'summarize'` for an out-of-band `ctx.summarize` call (R-C6). Both count against the budget.
|
|
51
|
+
*/
|
|
33
52
|
onLlmResult?: Function | undefined;
|
|
34
53
|
onToolResult?: Function | undefined;
|
|
35
54
|
/**
|
|
@@ -60,6 +79,7 @@ export class Loop {
|
|
|
60
79
|
store: import("../types").Store | null;
|
|
61
80
|
policy: Function | null;
|
|
62
81
|
assemble: Function | null;
|
|
82
|
+
trim: Function | null;
|
|
63
83
|
onLlmResult: Function | null;
|
|
64
84
|
onToolResult: Function | null;
|
|
65
85
|
_stopped: boolean;
|
package/src/loop.js
CHANGED
|
@@ -32,8 +32,21 @@ const { ToolError, HaltError } = require('./errors');
|
|
|
32
32
|
* thrown HaltError propagates. `ctx` is the per-run opaque blob (`run(msgs, tools, { ctx })`), the
|
|
33
33
|
* same object forwarded to `policy`; litectx reads `ctx.task` (intent) and `ctx.budget`. The
|
|
34
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.
|
|
36
|
-
*
|
|
35
|
+
* (src/context-units.js), which composes over this msgs-level seam. When `ctx` is an object, the
|
|
36
|
+
* Loop also lends a provider-bound `ctx.summarize(excerpt, opts?) => Promise<string>` (R-C6,
|
|
37
|
+
* non-enumerable): assemble calls it to roll a summary window — bareagent makes the one model
|
|
38
|
+
* call, the consumer owns the trigger/N/splice. Its usage is forwarded to `onLlmResult` so the
|
|
39
|
+
* summary tokens count against the budget.
|
|
40
|
+
* @property {Function} [trim] - async (msgs, ctx) => msgs. DESTRUCTIVE transcript-trim chokepoint (RT-2),
|
|
41
|
+
* the opposite of `assemble`: it BOUNDS the canonical transcript — the Loop replaces `msgs` with what
|
|
42
|
+
* this returns, evicting old turns AFTER they are harvested. Runs once per round before `assemble`.
|
|
43
|
+
* So eviction never drops un-persisted history, wire it via `unitTrimmer({ trim, onHarvest, policy })`
|
|
44
|
+
* (src/context-units.js), which performs the harvest-before-evict interlock over litectx's `trim` verb.
|
|
45
|
+
* An optional `.flush(msgs, ctx)` method is called on clean completion for the residual-window harvest.
|
|
46
|
+
* Fail-open (a trim fault degrades to no eviction that round); a thrown HaltError propagates.
|
|
47
|
+
* @property {Function} [onLlmResult] - async (event) => void after each LLM call; forwards usage to
|
|
48
|
+
* gate.record (via wireGate). `event.kind` discriminates the source: `'turn'` for a main-loop round,
|
|
49
|
+
* `'summarize'` for an out-of-band `ctx.summarize` call (R-C6). Both count against the budget.
|
|
37
50
|
* @property {Function} [onToolResult]
|
|
38
51
|
* @property {number} [maxRounds] - Removed in v0.8; presence throws a migration error.
|
|
39
52
|
*/
|
|
@@ -105,6 +118,38 @@ function estimateCost(model, usage) {
|
|
|
105
118
|
);
|
|
106
119
|
}
|
|
107
120
|
|
|
121
|
+
// R-C6: default instruction for the provider-bound `ctx.summarize` lent to the assemble seam.
|
|
122
|
+
const DEFAULT_SUMMARY_INSTRUCTION =
|
|
123
|
+
'You are a precise conversation summarizer. Produce a concise, factual summary of the following ' +
|
|
124
|
+
'conversation excerpt. Preserve concrete facts, decisions, and identifiers (names, ids, file ' +
|
|
125
|
+
'paths, numbers), and note any open or unresolved threads. Do not invent information. Output ' +
|
|
126
|
+
'only the summary prose, with no preamble.';
|
|
127
|
+
|
|
128
|
+
// Flatten an excerpt (array of OpenAI-format messages, or a raw string) into one prose block for the
|
|
129
|
+
// summarizer's single user turn. Rendering to text — rather than forwarding raw messages — sidesteps
|
|
130
|
+
// tool-call/result pairing entirely: a summary input never needs to be a valid wire transcript.
|
|
131
|
+
/**
|
|
132
|
+
* @param {Array<any>|string|null|undefined} excerpt
|
|
133
|
+
* @returns {string}
|
|
134
|
+
*/
|
|
135
|
+
function renderForSummary(excerpt) {
|
|
136
|
+
if (excerpt == null) return '';
|
|
137
|
+
if (typeof excerpt === 'string') return excerpt;
|
|
138
|
+
if (!Array.isArray(excerpt)) return String(excerpt);
|
|
139
|
+
const parts = [];
|
|
140
|
+
for (const m of excerpt) {
|
|
141
|
+
if (m == null) continue;
|
|
142
|
+
if (typeof m === 'string') { parts.push(m); continue; }
|
|
143
|
+
const role = m.role || 'message';
|
|
144
|
+
let text = m.content != null ? String(m.content) : '';
|
|
145
|
+
if (Array.isArray(m.tool_calls) && m.tool_calls.length) {
|
|
146
|
+
text += (text ? '\n' : '') + `[tool_calls: ${JSON.stringify(m.tool_calls)}]`;
|
|
147
|
+
}
|
|
148
|
+
parts.push(`${role}: ${text}`);
|
|
149
|
+
}
|
|
150
|
+
return parts.join('\n\n');
|
|
151
|
+
}
|
|
152
|
+
|
|
108
153
|
class Loop {
|
|
109
154
|
/**
|
|
110
155
|
* `policy` is async `(toolName, args, ctx) => true | string`. Recommended wiring: a closure
|
|
@@ -143,6 +188,15 @@ class Loop {
|
|
|
143
188
|
throw new Error('[Loop] options.assemble must be a function (msgs, info) => msgs');
|
|
144
189
|
}
|
|
145
190
|
this.assemble = options.assemble || null;
|
|
191
|
+
// RT-2: optional DESTRUCTIVE transcript-trim seam. Unlike `assemble` (a non-destructive view), `trim`
|
|
192
|
+
// bounds the canonical transcript — the Loop replaces `msgs` with what it returns, evicting old turns
|
|
193
|
+
// AFTER they've been harvested (the harvest-before-evict interlock lives in the trimmer; see
|
|
194
|
+
// src/context-units.js unitTrimmer). Opt-in: a Loop with no `trim` is unchanged. A `.flush` method on
|
|
195
|
+
// the function (if present) is called on clean completion for the F2 residual harvest.
|
|
196
|
+
if (options.trim != null && typeof options.trim !== 'function') {
|
|
197
|
+
throw new Error('[Loop] options.trim must be a function (msgs, ctx) => msgs (e.g. unitTrimmer({ trim, onHarvest, policy }))');
|
|
198
|
+
}
|
|
199
|
+
this.trim = options.trim || null;
|
|
146
200
|
if (options.onLlmResult != null && typeof options.onLlmResult !== 'function') {
|
|
147
201
|
throw new Error('[Loop] options.onLlmResult must be a function');
|
|
148
202
|
}
|
|
@@ -250,10 +304,92 @@ class Loop {
|
|
|
250
304
|
let lastUsage = { inputTokens: 0, outputTokens: 0 };
|
|
251
305
|
let totalCost = 0;
|
|
252
306
|
|
|
307
|
+
// R-C6: lend a provider-bound summarizer to the assemble seam via `ctx.summarize`. litectx owns
|
|
308
|
+
// the trigger/N/splice (its restorable COMPRESS path keeps summarized turns recoverable by id);
|
|
309
|
+
// bareagent lends ONLY the single model call. Attached NON-ENUMERABLE so it never shows up in the
|
|
310
|
+
// caller's ctx via JSON/iteration/deepEqual — preserving the `assemble(units, ctx)` identity
|
|
311
|
+
// contract (test/loop-assemble.test.js). `summarize(excerpt, opts?) => Promise<string>`:
|
|
312
|
+
// excerpt — array of OpenAI-format messages (or a raw string) litectx wants compressed
|
|
313
|
+
// opts — { instruction?, ...generateOpts } (instruction overrides the default; the rest pass
|
|
314
|
+
// through to provider.generate; temperature defaults to 0 for determinism)
|
|
315
|
+
// The summary call's usage is forwarded to onLlmResult so its tokens count against the budget
|
|
316
|
+
// (BA1 lineage — token-only flows must not be invisible to the gate); a HaltError there is a
|
|
317
|
+
// governance exit and propagates, matching the main-loop onLlmResult contract.
|
|
318
|
+
if (ctx && typeof ctx === 'object') {
|
|
319
|
+
const loop = this;
|
|
320
|
+
/**
|
|
321
|
+
* @param {Array<any>|string} excerpt
|
|
322
|
+
* @param {Record<string, any>} [opts]
|
|
323
|
+
* @returns {Promise<string>}
|
|
324
|
+
*/
|
|
325
|
+
const summarize = async (excerpt, opts = {}) => {
|
|
326
|
+
const { instruction, ...genOpts } = opts || {};
|
|
327
|
+
const prompt = [
|
|
328
|
+
{ role: 'system', content: instruction || DEFAULT_SUMMARY_INSTRUCTION },
|
|
329
|
+
{ role: 'user', content: renderForSummary(excerpt) },
|
|
330
|
+
];
|
|
331
|
+
const startedAt = Date.now();
|
|
332
|
+
const result = await loop.provider.generate(prompt, [], { temperature: 0, ...genOpts });
|
|
333
|
+
const usage = (result && result.usage) || null;
|
|
334
|
+
const model = loop.provider.model || null;
|
|
335
|
+
const cost = estimateCost(model, usage);
|
|
336
|
+
if (cost !== null) totalCost += cost;
|
|
337
|
+
loop._safeEmit({ type: 'loop:summarize', data: { usage, costUsd: cost, durationMs: Date.now() - startedAt } });
|
|
338
|
+
if (loop.onLlmResult) {
|
|
339
|
+
try {
|
|
340
|
+
await loop.onLlmResult({
|
|
341
|
+
model,
|
|
342
|
+
provider: loop.provider.name || null,
|
|
343
|
+
usage,
|
|
344
|
+
costUsd: cost,
|
|
345
|
+
durationMs: Date.now() - startedAt,
|
|
346
|
+
ctx,
|
|
347
|
+
kind: 'summarize',
|
|
348
|
+
});
|
|
349
|
+
} catch (err) {
|
|
350
|
+
if (err instanceof HaltError) throw err;
|
|
351
|
+
loop._reportError('onLlmResult', err, { phase: 'summarize' });
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return (result && result.text) || '';
|
|
355
|
+
};
|
|
356
|
+
// Fail-OPEN to match the assemble seam's own contract: a frozen / sealed / non-configurable ctx
|
|
357
|
+
// must NOT crash the agent. On failure the seam is simply unavailable (consumers already handle
|
|
358
|
+
// ctx.summarize being absent — it only exists when ctx is an object), reported, never silent.
|
|
359
|
+
try {
|
|
360
|
+
Object.defineProperty(ctx, 'summarize', { value: summarize, enumerable: false, configurable: true, writable: true });
|
|
361
|
+
} catch (err) {
|
|
362
|
+
this._reportError('summarize-attach', err);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
253
366
|
try {
|
|
254
367
|
for (let round = 0; round < HARD_ROUND_LIMIT; round++) {
|
|
255
368
|
if (this._stopped) break;
|
|
256
369
|
|
|
370
|
+
// RT-2: destructive transcript-trim chokepoint — bound the canonical transcript before assembling
|
|
371
|
+
// the window. Runs BEFORE assemble (trim shrinks canonical; assemble shapes the per-call view of
|
|
372
|
+
// what remains). The trimmer harvests every evicted turn BEFORE returning the smaller set, so this
|
|
373
|
+
// never drops un-persisted history. Mutate `msgs` IN PLACE (it's `const`, and result.msgs returns
|
|
374
|
+
// this same reference) → result.msgs becomes the bounded transcript; evicted turns live in the
|
|
375
|
+
// harvest store, restorable by id. Fail-OPEN: a trim fault degrades to no eviction this round (a
|
|
376
|
+
// context-bounding bug must not halt the agent); a HaltError (e.g. a write-gate deny during harvest)
|
|
377
|
+
// propagates as a clean governance exit — same contract as assemble/onLlmResult.
|
|
378
|
+
if (this.trim) {
|
|
379
|
+
try {
|
|
380
|
+
const before = msgs.length;
|
|
381
|
+
const kept = await this.trim(msgs, ctx);
|
|
382
|
+
if (Array.isArray(kept) && kept !== msgs) {
|
|
383
|
+
msgs.length = 0;
|
|
384
|
+
msgs.push(...kept);
|
|
385
|
+
if (msgs.length !== before) this._safeEmit({ type: 'loop:trim', data: { round, before, after: msgs.length } });
|
|
386
|
+
}
|
|
387
|
+
} catch (err) {
|
|
388
|
+
if (err instanceof HaltError) throw err;
|
|
389
|
+
this._reportError('trim', err, { round });
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
257
393
|
// RT-1: context-assembly chokepoint. Let a caller (e.g. a context-engineering library) shape
|
|
258
394
|
// the window sent to the provider this round. Returns a VIEW — the canonical `msgs` transcript
|
|
259
395
|
// is never mutated, so result.msgs stays complete and correct. Fail-OPEN: an assembly error
|
|
@@ -301,6 +437,7 @@ class Loop {
|
|
|
301
437
|
costUsd: roundCost,
|
|
302
438
|
durationMs: Date.now() - llmStartedAt,
|
|
303
439
|
ctx,
|
|
440
|
+
kind: 'turn',
|
|
304
441
|
});
|
|
305
442
|
} catch (err) {
|
|
306
443
|
if (err instanceof HaltError) throw err;
|
|
@@ -314,6 +451,15 @@ class Loop {
|
|
|
314
451
|
this._safeCall('onText', this.onText, result.text);
|
|
315
452
|
this._safeEmit({ type: 'loop:done', data: { text: result.text, usage: lastUsage, cost: totalCost } });
|
|
316
453
|
msgs.push({ role: 'assistant', content: result.text });
|
|
454
|
+
// RT-2 F2: residual harvest of the surviving window (incl. this final answer) on clean completion.
|
|
455
|
+
// `trim` only harvests EVICTED turns; without this, the never-evicted tail would diverge from an
|
|
456
|
+
// end-of-task batch. The trimmer's idempotent key means it never re-writes what eviction harvested.
|
|
457
|
+
// Fail-open / HaltError per the trim seam contract. No-op unless a `.flush`-capable trim is wired.
|
|
458
|
+
const flush = this.trim && /** @type {any} */ (this.trim).flush;
|
|
459
|
+
if (typeof flush === 'function') {
|
|
460
|
+
try { await flush(msgs, ctx); }
|
|
461
|
+
catch (err) { if (err instanceof HaltError) throw err; this._reportError('trim-flush', err, { round }); }
|
|
462
|
+
}
|
|
317
463
|
return { text: result.text, toolCalls: [], usage: lastUsage, cost: totalCost, error: null, msgs };
|
|
318
464
|
}
|
|
319
465
|
|