Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,68 @@

All notable changes to bare-agent are documented here. Format: [Keep a Changelog](https://keepachangelog.com/). Versioning: [SemVer](https://semver.org/).

## [0.33.1] - 2026-07-22

### Fixed

- **BA-17 — a native (`claude-mcp`) turn is one assistant MESSAGE, not one stream event.** Measured
on the real wire: the claude CLI emits a **separate `assistant` event per content block**, and each
one repeats that message's `usage` verbatim (one 13-block message arrived as 13 identical-usage
events). `createSessionStream` fired one `onTurn` per event, which corrupted both axes a caller
meters:
- **Turn axis** — a caller whose attempt bound is an LLM-turn count saw **14 "turns" for 2 real
ones** (7×; 4.4× on the adopter's failing job: 35 events for 8 turns). Its net then guillotined
the session at roughly *half* the allowance it advertised, and on the native path that routed to
`humanChannel → terminate`, discarding the worker's output entirely.
- **Token axis** — the same message's usage was added once per block: **5.04× inflated** against
the CLI's own session total, so a budget cap fired on tokens that were never spent.

A run of consecutive events sharing `message.id` is now ONE turn: usage recorded once, one `onTurn`.
Adjacent-run dedup (not a Set) so a recurring id still counts as a new turn — dropping a real turn
is the failure that matters. An event with **no** id degrades to one-turn-per-event, the pre-BA-17
behaviour, never a collapse of the session into a single turn.

**`--max-turns` itself was never the defect.** It was filed as "does not enforce, and counts
tool-calling turns"; both were measured false. It enforces (a 12-step task under `--max-turns 4`
stopped at 4 with the named `error_max_turns`) and it counts assistant turns (12 tool calls served
across 2 turns, inside `--max-turns 3`). The symptom was the mis-count above.

- **BA-17 — the turn bound is now bare-agent's guarantee, not an undocumented flag's.** `maxTurns`
still maps to `--max-turns` (which stops the session cleanly and emits the result event carrying
its real cost), and a parent-side counter now kills the session if a turn **beyond** N is ever
observed. Deliberately `>` and not `>=`: killing at exactly N on every bounded run would throw away
the only report of what the session cost. The backstop exists because `--max-turns` is undocumented
in `claude --help` — a rename would otherwise silently unbound every session.

- **BA-17 — a bounded native session returns its work (BA-5 on the native path).** The CLI reports
`result: null` when it stops on its own bound, so the provider returned `text: ''` — destroying the
only channel from one bounded attempt to the next. The last assistant turn's own text is now
carried forward, on the bound, on a guard terminal, and on a session we killed. The stop also
reports `stopReason: 'max_turns'` rather than `null` (a terminal we impose has no subtype to read
one from), via an own-property lookup — same proto-key guard as `SUBTYPE_MAP`.

- **BA-17 — the closing `kind:'session'` event now reconciles the token axis, not just money.** A
turn's `message.usage` is a snapshot taken when its first block was emitted and never revised
(measured: a turn that emitted ~816 output tokens reported 2, identically on all 13 of its events),
so the streamed per-turn sum is real but **short** of the session total. The closing event carries
the **residual** per tier (floored at 0 — a negative would be a credit that silently widens a cap),
so a wired gate's tokens now add up to exactly what the CLI itself reports. Verified on the live
wire: streamed + residual = 821 = the CLI's own output total.

- `GenerateResult.model` on the native path is read from the result event's `modelUsage` key via the
existing `mapClaudeMeta` helper. The result event has no `model` key, so this field — and the
closing session event's `model` — were always `null`.

### Notes

- No new public surface; `maxTurns`, `onTurn`, `session.turns` and `usage` keep their names and
change only to report honestly. Adopters bounding by LLM turns should expect **fewer** `onTurn`
events and **smaller, correct** token numbers than 0.33.0 reported.
- Evidence: `poc/ba17-turn-unit.mjs` (event-vs-turn + flag enforcement), `poc/ba17-unit-parallel.mjs`
(the flag's unit), `poc/ba17-verify-shipped.mjs` (the shipped code on a live session, both cases
green including the token reconciliation). +34 tests, every new guard mutation-proved in both
directions (19 mutations, all red).

## [0.33.0] - 2026-07-21

### Added
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ Node.js >= 18, pure JS + JSDoc, `node:test` for testing. Flat `src/` layout with
| Defer | tools/defer.js | Append `{id, action, when}` to JSONL queue for an external waker (cron + `examples/wake.sh`) to fire later. Two-phase gov: emit-time gate.check on `defer` action; fire-time gate.check on inner action. bareguard 0.2+ caps via `defer.ratePerMinute` |

Providers: OpenAI, Anthropic, Gemini, Ollama, CLIPipe, Fallback -- each in `src/provider-*.js`. All normalize `stopReason` (BA-6) to one neutral vocabulary via `src/provider-stop-reason.js` (`end_turn`/`max_tokens`/`tool_use`/`stop_sequence`/`refusal`/`pause_turn`/`context_exceeded`; unknown-or-absent ⇒ `null` ⇒ pre-BA-6 behavior, so an unmapped provider degrades to the status quo, never to a false truncation). All 5 verified live. `tool_use` is DERIVED where the API can't express it: Gemini/Ollama return `STOP`/`stop` on a complete tool call (no tool-call finish reason), so `normalizeStopReason(raw, provider, {hasToolCalls})` promotes `end_turn`→`tool_use` when the round carried a call — narrow by design (only `end_turn`; a truncated `max_tokens` round stays truncated, preserving BA-4's refusal; never touches refusal/pause/context/passthrough). All normalize `usage` to one neutral shape incl. prompt-cache tiers (`cacheReadTokens`/`cacheCreationTokens`; `inputTokens` = uncached remainder). Gemini is native `generateContent` (its OpenAI-compat endpoint drops the cache tier). Anthropic has opt-in `cacheSystem` (cache_control on the system prompt — weak on its own: the 1024–4096-tok model-dependent cache minimum means a short persona silently never caches) + **opt-in `cacheMessages` (BA-1)** = a rolling `cache_control` breakpoint on the LAST content block of the LAST message, so a tool loop stops re-buying its whole transcript at full price every round (measured: $0.0753→$0.0110/round, 6.8× steady-state; the 1.25× write is paid once). It MUST live in the provider — the transcript ends on a `tool_result` that `_toAnthropicMessage` rebuilds, so no caller seam (`assemble` included) can reach it. Copy-on-write (never mutate the caller's array — a stale mark accumulates; Anthropic allows ≤4). Caching pays for RE-SENDING, not GROWING; a destructive `trim` fold that rewrites the PREFIX invalidates it (fold the middle, keep the head). **Provider-native block passthrough (BA-7):** `Message.providerBlocks`/`GenerateResult.providerBlocks` = `{provider, model, blocks}` — the transcript field for content blocks the normalized `{text, toolCalls}` shape CANNOT express (Anthropic `thinking`/`redacted_thinking`), which the OpenAI-shaped `Message` had nowhere to put and so silently DROPPED (the API returns 200 either way — the loss never surfaced). Anthropic echoes thinking back unchanged (signature included) on a tool-use continuation; the provider now replays them at the FRONT of the assistant turn (thinking must lead). Three constraints: OPAQUE (keep bytes, never re-serialize from parsed fields — a `redacted_thinking` block can't survive it, and a `type==='thinking'` check would silently drop the next new block type = the same bug again); the provider+model TAG is enforced on replay (a signature is model-bound; mismatch ⇒ drop, degrade to the lossy-but-valid pre-BA-7 request, never a 400); and normalized `content`/`tool_calls` stay the SOURCE OF TRUTH (only unexpressible blocks ride along, so an `assemble`/`trim` seam that rewrites a message isn't silently undone by a stale cached copy of its text). Adaptive thinking is ALREADY DEFAULT on sonnet-5/Opus 4.7+ (measured: blocks on ~3/10 rounds with the param OMITTED; sending `thinking:{type:'adaptive'}` changed the rate not at all) — so the opt-in `thinking` option (forwarded to `body.thinking` VERBATIM/unvalidated, since `budget_tokens` already died and a reshaping library would need a release per API move) does NOT enable thinking; it pins the mode / reaches `display`/`effort`. **PROTOCOL fix, NOT a capability fix — the adopter's head-to-head found NO outcome difference; never re-sell it as one.** + `baseUrl`. Loop returns `result.metrics` (the meter: cumulative 4-tier tokens, byTool, costUsd null-not-zero, unpricedRounds, spawned, context.{compactions,summaries,tokensTrimmed}, memory.{stashed,episodes,recalls,stored,facts}) — `estimateCost` prices the 4 tiers separately. The §3.6 CE rollup: `context.tokensTrimmed` is an APPROXIMATE (~4 chars/token) estimate over the evicted span (no exact provider count); `memory.*` is reported via the loop-lent `ctx.recordMemoryOp` hook (channel A — the originating module announces, the loop counts + emits `loop:memory`), bounded PER RUN — `stashed`/`episodes` flow through the stash fold; the Memory wrapper is metered symmetrically opt-in via ctx: `recalls` (`Memory.search` reads) + `stored` (`Memory.store` writes; ctx in a trailing opts arg, never in persisted metadata). `memory.facts` is written by `remember` (the F5 consolidation pass) via the Store socket — disjoint from `stored`; litectx's own episode→fact promotion is a separate, litectx-internal op (§3.7)
CLIPipe tool mode has TWO shapes, and the choice between them is COST, not capability. **EMULATION (v0.32.0, `src/provider-clipipe-tools.js`, `toolProtocol:'claude'`):** the caller's tools are described in the system prompt, the CLI is constrained to a JSON envelope (`--json-schema`), the envelope is parsed back into `toolCalls`, and **the Loop keeps ownership of the cycle** (round accounting, spin guards, stop-reason classification apply per round). It re-sends the whole transcript every round — right for a CLI with NO MCP channel, WRONG for the claude CLI on cost. CLI reduced to a bare brain via `--tools '' --strict-mcp-config` + `--setting-sources ''` (MEASURED ~18× cut). Loud-failure by design: a weak model answering in prose is caught UPFRONT by a capability probe (behaviour-based, mirrors real-task shape, per BA-10) → model floor (sonnet-class+ for tools); a malformed envelope is a loud `ProviderError`; tools with no `toolProtocol` stay plain-text with a one-time warn. **NATIVE (BA-16, `src/provider-clipipe-mcp.js` + `src/mcp-bridge-stub.js`, `toolProtocol:'claude-mcp'` — the default for the claude CLI):** the CLI runs its OWN multi-turn session per `generate()` and executes the caller's tools natively over a unix-socket MCP bridge that calls back into the caller's in-process closures. The CLI owns the inner cycle — so the Loop's per-round machinery cannot run and the governance moves to the PROVIDER at the `tools/call` bridge (the one seam every call crosses): the SAME `policy` chokepoint (identical audit-row shape, zero gate changes), BA-11 deny-streak + BA-12 identical-error guards (same 3/3 narrowest triggers), and the turn bound via `--max-turns` (NAMED stop `error:'max_turns'`). `onTurn` STREAMS per-turn usage with all four cache tiers (never sum-at-end; a mid-run death has already surfaced its spend) then one closing `session` event with the authoritative cost — and when wired the Loop skips its own forward (billed once, never starved). **Provider owns its cycle (`Provider.ownsCycle`):** the Loop THROWS at construction on any option it could never honor — `assemble`/`trim`/`cacheMessages` and a Loop-level `policy` (which would be a fence silently not there) — no silently-dead knobs. `GenerateResult.session` + `metrics.sessionTurns` report the REAL turn/tool count (a 14-turn session never reads as one round); an internal terminal (bound, guard, or a **broken bridge** — a dead bridge still ends `subtype:'success'`, so it is caught parent-side by attempted-vs-served tool calls) surfaces as the run's `error`, never a laundered clean finish. **Cost is the claim (measured $0.25–0.55/round emulation vs ~$0.006/turn native); output-quality parity is deliberately UNMINTED (n=2).** Claude-only for now; both shapes isolated so a second CLI slots in behind the same seams.
CLIPipe tool mode has TWO shapes, and the choice between them is COST, not capability. **EMULATION (v0.32.0, `src/provider-clipipe-tools.js`, `toolProtocol:'claude'`):** the caller's tools are described in the system prompt, the CLI is constrained to a JSON envelope (`--json-schema`), the envelope is parsed back into `toolCalls`, and **the Loop keeps ownership of the cycle** (round accounting, spin guards, stop-reason classification apply per round). It re-sends the whole transcript every round — right for a CLI with NO MCP channel, WRONG for the claude CLI on cost. CLI reduced to a bare brain via `--tools '' --strict-mcp-config` + `--setting-sources ''` (MEASURED ~18× cut). Loud-failure by design: a weak model answering in prose is caught UPFRONT by a capability probe (behaviour-based, mirrors real-task shape, per BA-10) → model floor (sonnet-class+ for tools); a malformed envelope is a loud `ProviderError`; tools with no `toolProtocol` stay plain-text with a one-time warn. **NATIVE (BA-16, `src/provider-clipipe-mcp.js` + `src/mcp-bridge-stub.js`, `toolProtocol:'claude-mcp'` — the default for the claude CLI):** the CLI runs its OWN multi-turn session per `generate()` and executes the caller's tools natively over a unix-socket MCP bridge that calls back into the caller's in-process closures. The CLI owns the inner cycle — so the Loop's per-round machinery cannot run and the governance moves to the PROVIDER at the `tools/call` bridge (the one seam every call crosses): the SAME `policy` chokepoint (identical audit-row shape, zero gate changes), BA-11 deny-streak + BA-12 identical-error guards (same 3/3 narrowest triggers), and the turn bound (NAMED stop `error:'max_turns'` + `stopReason:'max_turns'`, work preserved per BA-5). **A TURN IS ONE ASSISTANT MESSAGE, NOT ONE STREAM EVENT (BA-17)** — the CLI emits a separate `assistant` event per content BLOCK, each repeating that message's `usage`, so counting events inflated a caller's turn axis 5–7× (guillotining a bounded worker at half its allowance) and its token axis 5.04×; a run of consecutive events sharing `message.id` is ONE turn (adjacent-run dedup, no-id ⇒ degrade to per-event). `maxTurns` is an LLM-TURN bound — the same unit as the Loop path, never a tool-call count — enforced BOTH by the CLI's `--max-turns` (which stops cleanly and emits the cost-bearing result event) and by a parent-side counter that kills only on an OVERRUN, because the flag is undocumented; the flag itself was measured to enforce correctly and in the right unit, so the ask that reported it broken was a mis-count, not a flag defect. `onTurn` STREAMS once per TURN with all four cache tiers (never sum-at-end; a mid-run death has already surfaced its spend) then one closing `session` event carrying the authoritative cost AND the token RESIDUAL — a turn's `usage` is an unrevised first-block snapshot, so the residual is what makes a gate's tiers add up to the CLI's own total; when wired the Loop skips its own forward (billed once, never starved). **Provider owns its cycle (`Provider.ownsCycle`):** the Loop THROWS at construction on any option it could never honor — `assemble`/`trim`/`cacheMessages` and a Loop-level `policy` (which would be a fence silently not there) — no silently-dead knobs. `GenerateResult.session` + `metrics.sessionTurns` report the REAL turn/tool count in MESSAGES (a 14-turn session never reads as one round, and a 2-turn session never reads as 14); an internal terminal (bound, guard, or a **broken bridge** — a dead bridge still ends `subtype:'success'`, so it is caught parent-side by attempted-vs-served tool calls) surfaces as the run's `error`, never a laundered clean finish. **Cost is the claim (measured $0.25–0.55/round emulation vs ~$0.006/turn native); output-quality parity is deliberately UNMINTED (n=2).** Claude-only for now; both shapes isolated so a second CLI slots in behind the same seams.

Stores: SQLiteStore (peer dep: better-sqlite3), JsonFileStore (zero deps) -- each in `src/store-*.js`
Tools: BrowsingTools (tools/browse.js, optional dep: barebrowse) — library tools for inline snapshots, CLI session (`npx barebrowse`) for token-efficient disk-based browsing
Expand Down
Loading