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
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,30 @@

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

## [0.33.0] - 2026-07-21

### Added

- **CLIPipe NATIVE tool mode (BA-16) — `toolProtocol:'claude-mcp'`.** The claude CLI has a real tool channel; v0.32.0's envelope emulation was built as if it did not. Native mode runs **one CLI session per call** and exposes the caller's `tools` to it as an **MCP server whose handlers call back into your own in-process closures** over a unix-socket bridge. The CLI owns the inner cycle and caches its transcript session-side.

**The claim is COST, and it is measured.** Emulation re-spawns the CLI and re-sends the whole rendered transcript every round, so it pays fresh `cache_creation` on the full prefix: the adopter measured **$0.25–0.55/round** on a real ~40-round job transcript, against **$0.0055–0.0074/turn** native (reproduced independently here). **NOT CLAIMED: better output quality** — there is n=2 suggestive evidence that emulation's JSON-questionnaire framing makes a model act less, it is deliberately **unminted**, and it must not be sold as a capability win (the BA-7 precedent). Emulation is **retained**, not retired: it is still the right instrument for a CLI with no MCP support. Native is the documented default for the claude CLI.

**What the Loop gives up, and what is bought back.** The CLI owns the turns, so the Loop's per-round machinery cannot run on them. That is stated rather than papered over, and everything load-bearing is re-established at the `tools/call` bridge — the one seam every call crosses:
- **The gate** rides the SAME `policy(tool, args, ctx)` chokepoint the Loop uses, so a wired bareguard writes **audit rows of identical shape with zero gate changes**. A deny is returned as a tool RESULT (advisory, allowlist-safe pivoting preserved); the handler never runs.
- **BA-11 deny-streak** and **BA-12 identical-tool-error** guards, same defaults (3/3) and same narrowest triggers — a byte-identical repeat only, so a model varying args while recovering is never punished. Both end the session with `denied:<tool>` / `stuck:<tool>`. The unknown/hallucinated-tool path feeds the same counter.
- **The turn bound** maps to the CLI's `--max-turns`, and its stop is NAMED and error-tagged (`error_max_turns` → `error:'max_turns'`), never a silent clean success.
- **The fence is set by the mode, not the caller:** `--tools '' --strict-mcp-config --setting-sources ''` always. The bridge is a **unix socket (0600, in a 0700 dir)** — never a listening TCP port, even on loopback.

**Genuinely lost, and made loud instead of silent:** `assemble`/`trim` and `cacheMessages` cannot apply (the provider owns the transcript), and a Loop-level `policy` would be **a fence that is silently not there**. All of them now **THROW at construction** rather than sit dead — no silently-dead knobs.

**Honest accounting.** `GenerateResult.session` carries the real `turns`/`toolCalls` and any internal terminal, and `metrics.sessionTurns` reports them — so a 14-turn session can never read as one cheap round. `onTurn` **streams** each completed turn's usage with all four cache tiers as it arrives (never summed at end, so a session that dies mid-run has already surfaced its spend), then one closing `kind:'session'` event carries the authoritative cost; when it is wired the Loop skips its own forward, so a session is **billed exactly once and never starved**.

**A pre-build measurement changed the design:** with the bridge dead, every tool call failed and the CLI **still ended `subtype:'success'`** — the model writes a tidy final answer explaining that its tools were broken. Mapping that onto `error:null` would report a run in which nothing worked as converged (the BA-4/5/6/13 optimistic-rounding class). So bridge health is tracked **parent-side** — MCP tool calls the CLI *attempted* vs the bridge actually *served* — and error-tags the run as `bridge-failed` regardless of the CLI's own subtype. **The CLI's subtype is not a sufficient success signal.**

+47 offline tests, every load-bearing guard mutation-proved in both directions (removing it goes red; making it fire always also goes red, so the negative controls are real). Live verify-shipped through a real `Loop` on a real session: `poc/ba16-native-shipped.mjs` (all green). Suite 963 tests / 961 pass / 0 fail / 2 skipped; typecheck clean.

*Note: `--max-turns` is **undocumented** in the claude CLI's `--help` (zero hits at 2.1.216, while unknown flags are rejected — so it exists but is version-fragile). The live check is the only tripwire that goes red if it is ever renamed.*

## [0.32.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 (v0.32.0, `src/provider-clipipe-tools.js`): `CLIPipeProvider({ toolProtocol:'claude' })` lets a CLI **subscription** drive a full agentic Loop (tools + turns), not just one-shot text — the point being run bareagent/bareloop off a Claude sub with no metered-API spend. **Option C, NOT an MCP callback:** the caller's tools are described in the system prompt, the CLI is constrained to a JSON envelope (`--json-schema`), and the envelope is parsed back into normalized `toolCalls` — so **the Loop keeps ownership of the cycle** (round accounting, spin guards, stop-reason classification all still apply; an MCP callback would hand turns to the CLI and lose them). CLI reduced to a bare brain via `--tools '' --strict-mcp-config` + `--setting-sources ''` (the last a MEASURED ~18× cost drop — the CLI else auto-loads the cwd's `CLAUDE.md`/memory every turn). **Loud-failure by design (the BA-6 family applied to a NEW provider):** a weak model that answers in prose instead of calling tools is caught UPFRONT by a capability probe (default on, behaviour-based not a model-list per BA-10; the probe must mirror real-task shape — a trivial "call this tool" instruction is a false positive haiku passes) → documented model floor (sonnet-class+ for tools; haiku fine for plain text); a malformed envelope is a loud `ProviderError`, never returned as prose; tools passed with no `toolProtocol` stay plain-text (backward compat) with a one-time warn. Claude-only for now; the CLI-specific flags/schema/parse/probe are isolated so a second CLI (codex/gemini) slots in behind the same seam.
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.

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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ console.log(result.count, result.matchedIds); // a code-derived count + the id

**Govern — one gate over both axes.** `wireGate(gate)` routes every LLM + tool call through one bareguard policy + audit + budget. Denied tools never reach the model; halts (turn / budget / content caps) exit cleanly. A plain deny stays advisory (the model can pivot to an allowed tool), but the Loop short-circuits a *spin* — `maxConsecutiveDenials` consecutive denials of the same action (default 3) stop the run with `error:'denied:<tool>'` instead of burning the budget to the cap; under `recurse` that surfaces as `{ incomplete, blocker:'governance-deny' }`. The same bound covers a tool that keeps *failing*: `maxIdenticalToolErrors` (default 3) stops a model re-sending a byte-identical call that cannot succeed, with `error:'stuck:<tool>'`. `require('bare-agent/bareguard')`

**Providers:** OpenAI-compatible (OpenAI, OpenRouter, Groq, vLLM, LM Studio), Anthropic, Gemini (native), Ollama, CLIPipe, Fallback — or bring your own (one `generate` method). All return the same shape; swap freely. Usage including prompt-cache tiers is normalized, so `result.metrics` reports honest cumulative tokens + cost — and `null`, never a silent `0`, for a model it couldn't price. A model that rejects a non-default `temperature` (e.g. `claude-sonnet-5`, OpenAI o1/gpt-5-class return a `400`) is handled gracefully — the provider drops the param and retries once rather than failing the call, surfacing `temperatureDropped` so a caller can report the effective value. **CLIPipe can drive a full agentic Loop — tools and turns — over a CLI *subscription* instead of the metered API** (`CLIPipeProvider({ command:'claude', args:['-p','--model','sonnet'], toolProtocol:'claude' })`): schema-validated tool emulation with the Loop keeping governance, and a capable model enforced upfront (v0.32.0, sonnet-class+ for tools; Claude CLI for now).
**Providers:** OpenAI-compatible (OpenAI, OpenRouter, Groq, vLLM, LM Studio), Anthropic, Gemini (native), Ollama, CLIPipe, Fallback — or bring your own (one `generate` method). All return the same shape; swap freely. Usage including prompt-cache tiers is normalized, so `result.metrics` reports honest cumulative tokens + cost — and `null`, never a silent `0`, for a model it couldn't price. A model that rejects a non-default `temperature` (e.g. `claude-sonnet-5`, OpenAI o1/gpt-5-class return a `400`) is handled gracefully — the provider drops the param and retries once rather than failing the call, surfacing `temperatureDropped` so a caller can report the effective value. **CLIPipe can drive a full agentic Loop — tools and turns — over a CLI *subscription* instead of the metered API.** Two modes, chosen by cost: `toolProtocol:'claude-mcp'` (v0.33.0, NATIVE — the CLI runs its own session and calls your tools over an MCP bridge; the gate, spin guards, and turn bound ride the provider, and it caches session-side — ~$0.006/turn) is the default for the Claude CLI, while `toolProtocol:'claude'` (v0.32.0, schema-validated emulation with the Loop keeping the cycle) stays right for a CLI with no MCP channel. A capable model is enforced upfront (sonnet-class+ for tools; Claude CLI for now).

**The run tells you the truth about what happened on the wire.** Every provider reports **why** generation ended (`stopReason`, normalized across all of them and surfaced on **every** `Loop.run()` return), so a round the API **cut off at the token cap** can no longer masquerade as a finished answer: it returns `error: 'truncated:max_tokens'` with the partial text preserved, instead of a silent `error: null` (BA-6). Its tool calls are **refused, never executed** — a *complete* tool call always arrives tagged `tool_use`, so one riding a truncated round was cut off mid-generation with arguments missing, which is exactly how a truncated `shell_write` can zero a file. The same honesty now covers **every** non-clean terminal signal (BA-13): a safety `refusal` returns `error: 'refusal'` and a blown context window returns `error: 'context_exceeded'` (both were previously laundered into an empty success), while a resumable `pause_turn` makes the loop resume rather than terminate. `error` is the sole success signal; a bound firing preserves the model's work rather than discarding it (BA-5).

Expand Down
Loading