bare-agent 0.16.2 → 0.19.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/src/loop.js CHANGED
@@ -7,6 +7,7 @@ const { ToolError, HaltError } = require('./errors');
7
7
  /** @typedef {import('../types').ToolDef} ToolDef */
8
8
  /** @typedef {import('../types').ToolCall} ToolCall */
9
9
  /** @typedef {import('../types').Usage} Usage */
10
+ /** @typedef {import('../types').RunMetrics} RunMetrics */
10
11
  /** @typedef {import('../types').GenerateResult} GenerateResult */
11
12
  /** @typedef {import('../types').Store} Store */
12
13
  /** @typedef {import('./checkpoint').Checkpoint} Checkpoint */
@@ -52,24 +53,36 @@ const { ToolError, HaltError } = require('./errors');
52
53
  */
53
54
 
54
55
  // Average pricing per 1K tokens (USD). Adjust these to match your provider's rates.
55
- // Last updated: 2026-05-18. Source: public provider pricing pages.
56
- /** @type {Record<string, {in: number, out: number}>} */
56
+ // Last updated: 2026-06-22. Source: public provider pricing pages (Anthropic rates + cache multipliers
57
+ // cross-checked against the claude-api reference). Rates are USD per 1K tokens. `cacheReadMult` /
58
+ // `cacheWriteMult` are multipliers ON the input rate for the two cache tiers (see estimateCost); when
59
+ // omitted they default to Anthropic's convention (read 0.1×, write 1.25×). OpenAI/Gemini have no
60
+ // cache-WRITE surcharge (their providers report cacheCreationTokens=0), so only cacheReadMult matters
61
+ // there. NOTE: OpenAI's cached discount is ~0.5× on the 4o family; some newer models (4.1/o-series) are
62
+ // ~0.25× — set to 0.5× here as the documented general value; refine per-model against current pricing.
63
+ /** @type {Record<string, {in: number, out: number, cacheReadMult?: number, cacheWriteMult?: number}>} */
57
64
  const COST_PER_1K = {
58
- // OpenAI
59
- 'gpt-4o': { in: 0.0025, out: 0.01 },
60
- 'gpt-4o-mini': { in: 0.00015, out: 0.0006 },
61
- 'gpt-4.1': { in: 0.002, out: 0.008 },
62
- 'gpt-4.1-mini': { in: 0.0004, out: 0.0016 },
63
- 'gpt-4.1-nano': { in: 0.0001, out: 0.0004 },
64
- 'o3-mini': { in: 0.0011, out: 0.0044 },
65
- // Anthropic — Claude 4.x current generation (2026-05)
66
- 'claude-opus-4-7': { in: 0.015, out: 0.075 },
65
+ // OpenAI — cached input ~0.5× (no write tier)
66
+ 'gpt-4o': { in: 0.0025, out: 0.01, cacheReadMult: 0.5 },
67
+ 'gpt-4o-mini': { in: 0.00015, out: 0.0006, cacheReadMult: 0.5 },
68
+ 'gpt-4.1': { in: 0.002, out: 0.008, cacheReadMult: 0.5 },
69
+ 'gpt-4.1-mini': { in: 0.0004, out: 0.0016, cacheReadMult: 0.5 },
70
+ 'gpt-4.1-nano': { in: 0.0001, out: 0.0004, cacheReadMult: 0.5 },
71
+ 'o3-mini': { in: 0.0011, out: 0.0044, cacheReadMult: 0.5 },
72
+ // Anthropic — Claude current generation (2026-06). Cache tiers use the default 0.1×/1.25×.
73
+ 'claude-fable-5': { in: 0.01, out: 0.05 },
74
+ 'claude-opus-4-8': { in: 0.005, out: 0.025 },
75
+ 'claude-opus-4-7': { in: 0.005, out: 0.025 },
76
+ 'claude-opus-4-6': { in: 0.005, out: 0.025 },
67
77
  'claude-sonnet-4-6': { in: 0.003, out: 0.015 },
68
- 'claude-haiku-4-5-20251001': { in: 0.0008, out: 0.004 },
69
- 'claude-haiku-4-5': { in: 0.0008, out: 0.004 },
70
- // Anthropic — earlier 4.x snapshots
78
+ 'claude-haiku-4-5-20251001': { in: 0.001, out: 0.005 },
79
+ 'claude-haiku-4-5': { in: 0.001, out: 0.005 },
80
+ // Anthropic — earlier snapshots (the original Opus 4 / Sonnet 4 generation, genuinely different rates)
71
81
  'claude-sonnet-4-20250514': { in: 0.003, out: 0.015 },
72
82
  'claude-opus-4-20250514': { in: 0.015, out: 0.075 },
83
+ // Google Gemini — cached content ~0.25× (no write tier). Native provider lands in a following piece.
84
+ 'gemini-2.5-flash': { in: 0.0003, out: 0.0025, cacheReadMult: 0.25 },
85
+ 'gemini-2.5-pro': { in: 0.00125, out: 0.01, cacheReadMult: 0.25 },
73
86
  // Fallback average across popular models (~$0.002 in, ~$0.008 out per 1K)
74
87
  '_default': { in: 0.002, out: 0.008 },
75
88
  };
@@ -105,6 +118,11 @@ function sealDanglingToolCalls(msgs, rule) {
105
118
  }
106
119
 
107
120
  /**
121
+ * Estimate the USD cost of one round's usage, pricing the FOUR token tiers separately (D9/L7):
122
+ * uncached input, output, cache-read, and cache-creation. Folding cache tokens into the full input
123
+ * rate mis-prices badly — a warm prompt is mostly cache-read (~0.1–0.5× input) and Anthropic's
124
+ * cache-creation is a ~1.25× premium — so each tier gets its own rate. Returns null (not 0) when the
125
+ * model is unknown/absent so the caller can mark the round `unpriced` rather than silently free.
108
126
  * @param {string|null} model
109
127
  * @param {Usage|null} usage
110
128
  * @returns {number|null}
@@ -112,10 +130,20 @@ function sealDanglingToolCalls(msgs, rule) {
112
130
  function estimateCost(model, usage) {
113
131
  if (!usage || !model) return null;
114
132
  const rates = COST_PER_1K[model] || COST_PER_1K['_default'];
115
- return (
116
- ((usage.inputTokens || 0) * rates.in +
117
- (usage.outputTokens || 0) * rates.out) / 1000
118
- );
133
+ const readMult = rates.cacheReadMult ?? 0.1; // Anthropic convention when unspecified
134
+ const writeMult = rates.cacheWriteMult ?? 1.25;
135
+ const cost = (
136
+ (usage.inputTokens || 0) * rates.in +
137
+ (usage.outputTokens || 0) * rates.out +
138
+ (usage.cacheReadTokens || 0) * rates.in * readMult +
139
+ (usage.cacheCreationTokens || 0) * rates.in * writeMult
140
+ ) / 1000;
141
+ // A non-finite cost (±Infinity from runaway token counts, NaN from a garbage rate-table entry) is a
142
+ // COULDN'T-PRICE, not a price. Return null so the round is marked `unpriced` and the value never
143
+ // poisons `totalCost`, `result.metrics.costUsd`, or — via onLlmResult → the gate — `spentUsd`. The
144
+ // last is the dangerous one: `NaN >= cap` is false, which would DISABLE a budget cap, not just
145
+ // under-count it. Same silent-unenforceable class as a null model (§3.7).
146
+ return Number.isFinite(cost) ? cost : null;
119
147
  }
120
148
 
121
149
  // R-C6: default instruction for the provider-bound `ctx.summarize` lent to the assemble seam.
@@ -262,9 +290,11 @@ class Loop {
262
290
  /**
263
291
  * Run the think/act/observe loop.
264
292
  * @param {Message[]} messages - Conversation messages in OpenAI format.
265
- * @param {ToolDef[]} [tools=[]] - Tool definitions with name, execute, description, parameters.
293
+ * @param {ToolDef[] | (() => ToolDef[])} [tools=[]] - Tool definitions, or a thunk returning them. A
294
+ * thunk is re-evaluated each round (D4/eval-assist F2) so a tool set that grows mid-run — e.g. a skill
295
+ * unlocking its tools — is offered on the next round; a static array is resolved once at wire time.
266
296
  * @param {Record<string, any>} [options={}] - Per-run overrides (system, temperature, ctx, etc.).
267
- * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, msgs: Message[]}>}
297
+ * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, msgs: Message[], metrics: RunMetrics}>}
268
298
  * On halt the returned `error` is `halt:<rule>` (or `halt:unknown` if the
269
299
  * thrown HaltError carried no `rule`), and `msgs` is sanitized so any
270
300
  * dangling assistant `tool_calls` from the halted round are paired with
@@ -281,29 +311,118 @@ class Loop {
281
311
  const msgs = system
282
312
  ? [{ role: 'system', content: system }, ...messages]
283
313
  : [...messages];
284
- const toolMap = new Map(tools.map(t => [t.name, t]));
285
-
286
- // Validate tools at wire time
287
- for (const tool of tools) {
288
- if (typeof tool.name !== 'string' || !tool.name) {
289
- throw new Error(`[Loop] Tool is missing a name (got ${JSON.stringify(tool.name)}). Every tool must have a non-empty string name.`);
290
- }
291
- if (typeof tool.execute !== 'function') {
292
- throw new Error(`[Loop] Tool "${tool.name}" is missing an execute() function.`);
314
+ // D4 (eval-assist F2): `tools` may be a `() => ToolDef[]` thunk, re-evaluated each round so a set that
315
+ // grows mid-run (e.g. a skill unlocking its tools via skill_use last round) is offered to the model on
316
+ // the NEXT round's generate(). Loop stays skill-agnostic — it only gains the general tools-as-thunk
317
+ // capability, the same independence as the assemble/trim seams. A static array keeps today's exact
318
+ // behavior (resolved once at wire time, never changes).
319
+ const toolsThunk = typeof tools === 'function' ? tools : null;
320
+ const resolveTools = () => {
321
+ const list = toolsThunk ? toolsThunk() : tools;
322
+ if (!Array.isArray(list)) {
323
+ throw new Error(`[Loop] tools must be an array of ToolDef or a () => ToolDef[] thunk returning one, got ${typeof list}.`);
293
324
  }
294
- if (tool.description !== undefined && typeof tool.description !== 'string') {
295
- console.warn(`[Loop] Tool "${tool.name}" has a non-string description — providers may ignore it.`);
296
- }
297
- if (tool.parameters !== undefined && (typeof tool.parameters !== 'object' || tool.parameters === null)) {
298
- throw new Error(`[Loop] Tool "${tool.name}" has invalid parameters — expected an object, got ${typeof tool.parameters}.`);
325
+ return list;
326
+ };
327
+ // A non-string description is a soft warning, not a throw — but validateTools re-runs every round for a
328
+ // thunk, so dedup the warning by tool name to avoid per-round log spam (warn once per offending tool/run).
329
+ const warnedBadDesc = new Set();
330
+ /** Validate a resolved tool list — wire-time contract, re-checked each round for thunks. @param {ToolDef[]} list */
331
+ const validateTools = (list) => {
332
+ for (const tool of list) {
333
+ if (typeof tool.name !== 'string' || !tool.name) {
334
+ throw new Error(`[Loop] Tool is missing a name (got ${JSON.stringify(tool.name)}). Every tool must have a non-empty string name.`);
335
+ }
336
+ if (typeof tool.execute !== 'function') {
337
+ throw new Error(`[Loop] Tool "${tool.name}" is missing an execute() function.`);
338
+ }
339
+ if (tool.description !== undefined && typeof tool.description !== 'string' && !warnedBadDesc.has(tool.name)) {
340
+ warnedBadDesc.add(tool.name);
341
+ console.warn(`[Loop] Tool "${tool.name}" has a non-string description — providers may ignore it.`);
342
+ }
343
+ if (tool.parameters !== undefined && (typeof tool.parameters !== 'object' || tool.parameters === null)) {
344
+ throw new Error(`[Loop] Tool "${tool.name}" has invalid parameters — expected an object, got ${typeof tool.parameters}.`);
345
+ }
299
346
  }
300
- }
347
+ };
348
+
349
+ // Resolve + validate once at wire time (this throws for a misconfigured static array OR thunk —
350
+ // fail-fast on setup). Per-round re-resolution for thunks happens at the top of the loop below.
351
+ let activeTools = resolveTools();
352
+ validateTools(activeTools);
353
+ let toolMap = new Map(activeTools.map(t => [t.name, t]));
301
354
 
302
355
  this._safeEmit({ type: 'loop:start', data: { messageCount: msgs.length } });
303
356
 
304
357
  let lastUsage = { inputTokens: 0, outputTokens: 0 };
305
358
  let totalCost = 0;
306
359
 
360
+ // The meter (Feature 3): bareagent is the canonical run counter. Accumulates across rounds and is
361
+ // returned as `result.metrics`. `tokens` is CUMULATIVE over all four tiers (fixes the last-round-only
362
+ // `result.usage` bug — result.usage stays last-round for back-compat; metrics.tokens is the run total).
363
+ // `costUsd` is the priced cumulative (null only if NOTHING could be priced — the explicit-unknown
364
+ // signal, distinct from a genuine 0); `unpricedRounds` makes an unenforceable-on-budget run visible.
365
+ const meterStartedAt = Date.now();
366
+ let pricedAny = false;
367
+ const metrics = {
368
+ turns: 0,
369
+ toolCalls: 0,
370
+ /** @type {Record<string, number>} */
371
+ byTool: {},
372
+ tokens: { input: 0, output: 0, cacheCreation: 0, cacheRead: 0 },
373
+ unpricedRounds: 0,
374
+ // §3.6 CE-activity rollup — convenience counts derived in-place from the same events already
375
+ // on the Stream (loop:trim, loop:summarize), not a second source. `compactions` counts
376
+ // destructive trim evictions; `summaries` counts ctx.summarize calls; `tokensTrimmed` is an
377
+ // APPROXIMATE (~4 chars/token) count of tokens evicted from the canonical transcript — evicted
378
+ // spans have no exact provider count, so we estimate rather than emit a silent zero (§3.10).
379
+ context: { compactions: 0, summaries: 0, tokensTrimmed: 0 },
380
+ // §3.6 memory footprint — the ops bareagent INITIATES, reported via the loop-lent `ctx.recordMemoryOp`
381
+ // hook (channel A: the originating module announces; the loop counts + emits `loop:memory`). Bounded
382
+ // PER RUN by construction: the hook is re-attached each loop.run() and closes over THIS run's meter,
383
+ // and result.metrics is a copy taken at run end. `stashed`/`episodes` flow through the stash fold;
384
+ // The Memory wrapper is metered on BOTH sides, symmetrically and opt-in: `recalls` (Memory.search,
385
+ // the read) and `stored` (Memory.store, the generic durable write). The caller threads the run's ctx so
386
+ // the op counts against that run (a session-scoped LiteCtx → bounded to that session). `facts` now has its
387
+ // writer: `remember` (src/remember.js, the consolidation pass) distills durable facts and announces each via
388
+ // recordMemoryOp('facts'). It is DISJOINT from `stored` (remember writes through the socket WITHOUT ctx, so a
389
+ // distilled fact counts once, as a fact). litectx's own episode→fact promotion stays litectx-internal to
390
+ // surface (recentActivity/promotionCandidates) — that is NOT this counter.
391
+ memory: { stashed: 0, episodes: 0, recalls: 0, stored: 0, facts: 0 },
392
+ };
393
+ /** Accumulate one usage object into the cumulative token tiers. @param {Usage|null|undefined} u */
394
+ const addUsage = (u) => {
395
+ if (!u) return;
396
+ metrics.tokens.input += u.inputTokens || 0;
397
+ metrics.tokens.output += u.outputTokens || 0;
398
+ metrics.tokens.cacheCreation += u.cacheCreationTokens || 0;
399
+ metrics.tokens.cacheRead += u.cacheReadTokens || 0;
400
+ };
401
+ /** Snapshot the meter for a return — finalizes the run-scoped fields. @returns {RunMetrics} */
402
+ const finalizeMetrics = () => ({
403
+ turns: metrics.turns,
404
+ toolCalls: metrics.toolCalls,
405
+ byTool: metrics.byTool,
406
+ tokens: { ...metrics.tokens },
407
+ costUsd: pricedAny ? totalCost : null,
408
+ unpricedRounds: metrics.unpricedRounds,
409
+ spawned: metrics.byTool.spawn || 0, // §3.6 — spawn-tool invocations (byTool counts every call, incl. denied)
410
+ context: { ...metrics.context }, // §3.6 CE-activity rollup
411
+ memory: { ...metrics.memory }, // §3.6 memory footprint (stashed/episodes/recalls/stored/facts; see init note)
412
+ durationMs: Date.now() - meterStartedAt,
413
+ });
414
+ // Approximate token count of a message array (~4 chars/token over the stringified message).
415
+ // Used ONLY for the observability rollup metrics.context.tokensTrimmed — NEVER for pricing or
416
+ // governance, which use exact provider counts. A deliberate estimate (§3.10): the trim event
417
+ // carries message COUNTS and evicted spans have no exact provider token count.
418
+ /** @param {any[]} arr @returns {number} */
419
+ const estimateTokens = (arr) => {
420
+ if (!Array.isArray(arr)) return 0;
421
+ let chars = 0;
422
+ for (const m of arr) { try { chars += JSON.stringify(m).length; } catch { /* unstringifiable — skip */ } }
423
+ return Math.ceil(chars / 4);
424
+ };
425
+
307
426
  // R-C6: lend a provider-bound summarizer to the assemble seam via `ctx.summarize`. litectx owns
308
427
  // the trigger/N/splice (its restorable COMPRESS path keeps summarized turns recoverable by id);
309
428
  // bareagent lends ONLY the single model call. Attached NON-ENUMERABLE so it never shows up in the
@@ -333,7 +452,9 @@ class Loop {
333
452
  const usage = (result && result.usage) || null;
334
453
  const model = (result && result.model) || loop.provider.model || null;
335
454
  const cost = estimateCost(model, usage);
336
- if (cost !== null) totalCost += cost;
455
+ if (cost !== null) { totalCost += cost; pricedAny = true; }
456
+ addUsage(usage); // summarize tokens are real spend → count them in the cumulative meter
457
+ metrics.context.summaries++; // §3.6 CE-activity rollup
337
458
  loop._safeEmit({ type: 'loop:summarize', data: { usage, costUsd: cost, durationMs: Date.now() - startedAt } });
338
459
  if (loop.onLlmResult) {
339
460
  try {
@@ -342,6 +463,7 @@ class Loop {
342
463
  provider: loop.provider.name || null,
343
464
  usage,
344
465
  costUsd: cost,
466
+ pricing: cost === null ? 'unpriced' : 'priced',
345
467
  durationMs: Date.now() - startedAt,
346
468
  ctx,
347
469
  kind: 'summarize',
@@ -361,12 +483,46 @@ class Loop {
361
483
  } catch (err) {
362
484
  this._reportError('summarize-attach', err);
363
485
  }
486
+ // §3.6 memory footprint (channel A). Lend a recorder the originating module calls when it
487
+ // initiates a memory op (stash.js at a lossless park → 'stashed'; at an episode write → 'episodes').
488
+ // The loop owns the count + the Stream emit; the module stays Loop-agnostic (it just calls an
489
+ // optional ctx hook, exactly like ctx.summarize). Non-enumerable so it never leaks into the
490
+ // assemble(units, ctx) identity contract. Unknown kinds are ignored (forward-compatible).
491
+ const recordMemoryOp = (/** @type {string} */ kind) => {
492
+ if (Object.prototype.hasOwnProperty.call(metrics.memory, kind)) {
493
+ metrics.memory[kind]++;
494
+ loop._safeEmit({ type: 'loop:memory', data: { op: kind } });
495
+ }
496
+ };
497
+ try {
498
+ Object.defineProperty(ctx, 'recordMemoryOp', { value: recordMemoryOp, enumerable: false, configurable: true, writable: true });
499
+ } catch (err) {
500
+ this._reportError('recordMemoryOp-attach', err);
501
+ }
364
502
  }
365
503
 
366
504
  try {
367
505
  for (let round = 0; round < HARD_ROUND_LIMIT; round++) {
368
506
  if (this._stopped) break;
369
507
 
508
+ // D4: re-evaluate the tools thunk for THIS round, so a tool unlocked last round (e.g. by skill_use)
509
+ // is now offered to the model. Static arrays skip this (toolsThunk === null) and keep their wire-time
510
+ // set. Round 0 also skips — the wire-time resolution above already produced its set, so the thunk is
511
+ // called exactly once per round, never twice for round 0. Fail-OPEN like the assemble/trim seams: a
512
+ // thunk fault degrades to the previous round's set (a tool-discovery bug must not halt the agent); a
513
+ // HaltError is a governance exit and propagates.
514
+ if (toolsThunk && round > 0) {
515
+ try {
516
+ const next = resolveTools();
517
+ validateTools(next);
518
+ activeTools = next;
519
+ toolMap = new Map(activeTools.map(t => [t.name, t]));
520
+ } catch (err) {
521
+ if (err instanceof HaltError) throw err;
522
+ this._reportError('tools', err, { round });
523
+ }
524
+ }
525
+
370
526
  // RT-2: destructive transcript-trim chokepoint — bound the canonical transcript before assembling
371
527
  // the window. Runs BEFORE assemble (trim shrinks canonical; assemble shapes the per-call view of
372
528
  // what remains). The trimmer harvests every evicted turn BEFORE returning the smaller set, so this
@@ -378,11 +534,17 @@ class Loop {
378
534
  if (this.trim) {
379
535
  try {
380
536
  const before = msgs.length;
537
+ const beforeTokens = estimateTokens(msgs); // pre-trim estimate (msgs not yet spliced)
381
538
  const kept = await this.trim(msgs, ctx);
382
539
  if (Array.isArray(kept) && kept !== msgs) {
540
+ const trimmed = beforeTokens - estimateTokens(kept); // approx tokens evicted from the transcript
383
541
  msgs.length = 0;
384
542
  msgs.push(...kept);
385
- if (msgs.length !== before) this._safeEmit({ type: 'loop:trim', data: { round, before, after: msgs.length } });
543
+ if (msgs.length !== before) {
544
+ metrics.context.compactions++;
545
+ if (trimmed > 0) metrics.context.tokensTrimmed += trimmed;
546
+ this._safeEmit({ type: 'loop:trim', data: { round, before, after: msgs.length, tokensTrimmed: trimmed > 0 ? trimmed : 0 } });
547
+ }
386
548
  }
387
549
  } catch (err) {
388
550
  if (err instanceof HaltError) throw err;
@@ -412,21 +574,36 @@ class Loop {
412
574
  let result;
413
575
  const llmStartedAt = Date.now();
414
576
  try {
415
- const generate = () => this.provider.generate(toSend, tools, options);
577
+ const generate = () => this.provider.generate(toSend, activeTools, options);
416
578
  result = this.retry ? await this.retry.call(generate) : await generate();
417
579
  } catch (err) {
418
580
  this._reportError('provider', err, { round });
419
581
  if (this.throwOnError) throw err;
420
- return { text: '', toolCalls: [], usage: lastUsage, cost: totalCost, error: err.message, msgs };
582
+ return { text: '', toolCalls: [], usage: lastUsage, cost: totalCost, error: err.message, msgs, metrics: finalizeMetrics() };
421
583
  }
422
584
 
423
585
  lastUsage = result.usage || lastUsage;
586
+ // Publish the latest measured usage to ctx (non-enumerable, fail-open) so a transcript-bound seam —
587
+ // e.g. F2 stash auto-compaction — can read EXACT provider-counted `inputTokens` to gauge context
588
+ // pressure on the NEXT round's trim. Symmetric with lending ctx.summarize; the Loop stays unaware of
589
+ // the consumer. A frozen/sealed ctx simply doesn't get it (reported, never fatal).
590
+ if (ctx && typeof ctx === 'object') {
591
+ try { Object.defineProperty(ctx, 'usage', { value: lastUsage, enumerable: false, configurable: true, writable: true }); }
592
+ catch (err) { this._reportError('usage-attach', err); }
593
+ }
424
594
  // Prefer the model the response reports (robust when provider.model is absent or varies per
425
595
  // response — e.g. FallbackProvider, or a CircuitBreaker-wrapped provider that drops .model).
426
596
  const model = result.model || this.provider.model || null;
427
597
  const roundCost = estimateCost(model, lastUsage);
428
598
  if (roundCost !== null) totalCost += roundCost;
429
599
 
600
+ // Meter this round: count the turn, accumulate the four token tiers, and classify pricing —
601
+ // an unpriced round (null cost: no model / no rate) is tallied so the run is observably
602
+ // unenforceable on budget rather than silently free (the #3 cost contract).
603
+ metrics.turns++;
604
+ addUsage(result.usage);
605
+ if (roundCost === null) metrics.unpricedRounds++; else pricedAny = true;
606
+
430
607
  // BA1: forward LLM usage to gate.record (via wireGate) so budget.maxCostUsd
431
608
  // covers token-heavy / tool-light workloads. Callback errors route through
432
609
  // _reportError but never kill the loop — governance failure ≠ run failure.
@@ -437,6 +614,9 @@ class Loop {
437
614
  provider: this.provider.name || null,
438
615
  usage: result.usage || null,
439
616
  costUsd: roundCost,
617
+ // Priced vs unpriced is explicit so the gate never mistakes "couldn't price" (null) for
618
+ // "free" (0) — the silent-zero that made #3's budget cap a no-op. (D5 / §3.7.)
619
+ pricing: roundCost === null ? 'unpriced' : 'priced',
440
620
  durationMs: Date.now() - llmStartedAt,
441
621
  ctx,
442
622
  kind: 'turn',
@@ -462,7 +642,7 @@ class Loop {
462
642
  try { await flush(msgs, ctx); }
463
643
  catch (err) { if (err instanceof HaltError) throw err; this._reportError('trim-flush', err, { round }); }
464
644
  }
465
- return { text: result.text, toolCalls: [], usage: lastUsage, cost: totalCost, error: null, msgs };
645
+ return { text: result.text, toolCalls: [], usage: lastUsage, cost: totalCost, error: null, msgs, metrics: finalizeMetrics() };
466
646
  }
467
647
 
468
648
  // Execute tool calls
@@ -479,6 +659,11 @@ class Loop {
479
659
  for (const tc of result.toolCalls) {
480
660
  if (this._stopped) break;
481
661
 
662
+ // Meter every tool call the model makes (per-tool tally), regardless of outcome — a denied or
663
+ // unknown call is still an invocation the operator wants to see.
664
+ metrics.toolCalls++;
665
+ metrics.byTool[tc.name] = (metrics.byTool[tc.name] || 0) + 1;
666
+
482
667
  const tool = toolMap.get(tc.name);
483
668
  if (!tool) {
484
669
  const errMsg = `[Loop] Unknown tool: ${tc.name}`;
@@ -555,6 +740,10 @@ class Loop {
555
740
  msgs.push({ role: 'tool', tool_call_id: tc.id, content });
556
741
  this._safeEmit({ type: 'loop:tool_result', data: { tool: tc.name, result: content } });
557
742
  } catch (err) {
743
+ // A HaltError from a tool body is a deliberate governance exit, not a tool failure — re-throw it
744
+ // like every other seam (the outer catch pairs dangling tool_calls + returns halt cleanly). Ordinary
745
+ // errors (plain Error) still become a ToolError, preserving the tool-as-untrusted-execution boundary.
746
+ if (err instanceof HaltError) throw err;
558
747
  toolError = err instanceof ToolError ? err : new ToolError(err.message, { context: { tool: tc.name } });
559
748
  const errMsg = `[Loop] Tool error: ${toolError.message}`;
560
749
  msgs.push({ role: 'tool', tool_call_id: tc.id, content: errMsg });
@@ -592,7 +781,7 @@ class Loop {
592
781
  sealDanglingToolCalls(msgs, rule);
593
782
  this._reportError('halt', err, { rule, reason: err.decision?.reason ?? null });
594
783
  this._safeEmit({ type: 'loop:done', data: { text: '', halted: true, rule, cost: totalCost } });
595
- return { text: '', toolCalls: [], usage: lastUsage, cost: totalCost, error: `halt:${rule}`, msgs };
784
+ return { text: '', toolCalls: [], usage: lastUsage, cost: totalCost, error: `halt:${rule}`, msgs, metrics: finalizeMetrics() };
596
785
  }
597
786
  throw err;
598
787
  }
@@ -601,7 +790,7 @@ class Loop {
601
790
  // limits.maxTurns (or the LLM's natural completion) ends the loop first.
602
791
  const warning = `[Loop] hit internal safety limit of ${HARD_ROUND_LIMIT} rounds. Wire bareguard for proper governance — see bare-agent/bareguard.`;
603
792
  this._safeEmit({ type: 'loop:done', data: { text: '', warning, cost: totalCost } });
604
- return { text: '', toolCalls: [], usage: lastUsage, cost: totalCost, error: warning, msgs };
793
+ return { text: '', toolCalls: [], usage: lastUsage, cost: totalCost, error: warning, msgs, metrics: finalizeMetrics() };
605
794
  }
606
795
 
607
796
  /**
@@ -674,7 +863,7 @@ class Loop {
674
863
  * @param {string} text - User message.
675
864
  * @param {ToolDef[]} [tools=[]] - Tool definitions.
676
865
  * @param {Record<string, any>} [options={}] - Per-run overrides.
677
- * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, msgs: Message[]}>}
866
+ * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, msgs: Message[], metrics: RunMetrics}>}
678
867
  */
679
868
  async chat(text, tools = [], options = {}) {
680
869
  this._history.push({ role: 'user', content: text });
@@ -691,4 +880,4 @@ class Loop {
691
880
  }
692
881
  }
693
882
 
694
- module.exports = { Loop };
883
+ module.exports = { Loop, estimateCost, COST_PER_1K };
package/src/memory.d.ts CHANGED
@@ -27,13 +27,20 @@ export class Memory {
27
27
  _store: Store;
28
28
  /**
29
29
  * @param {any} content
30
- * @param {Record<string, any>} [metadata]
30
+ * @param {Record<string, any>} [metadata] - Stored alongside the content; persisted to the backend.
31
+ * @param {Record<string, any>} [opts] - Transient call options (NOT persisted). If `opts.ctx` carries a
32
+ * Loop-lent `recordMemoryOp` hook, this write is counted against that run's `result.metrics.memory.stored`
33
+ * (§3.6, opt-in — the write-side mirror of search's `recalls`). `ctx` rides here, never in `metadata`,
34
+ * precisely because `metadata` is persisted. Memory stays Loop-agnostic: it only calls an optional hook.
31
35
  * @returns {any} id
32
36
  */
33
- store(content: any, metadata?: Record<string, any>): any;
37
+ store(content: any, metadata?: Record<string, any>, opts?: Record<string, any>): any;
34
38
  /**
35
39
  * @param {string} query
36
- * @param {Record<string, any>} [options]
40
+ * @param {Record<string, any>} [options] - Store search options. If `options.ctx` carries a Loop-lent
41
+ * `recordMemoryOp` hook, this recall is counted against that run's `result.metrics.memory.recalls`
42
+ * (§3.6, opt-in — the hook is absent unless the caller threads the run's ctx). Memory stays
43
+ * Loop-agnostic: it only calls an optional hook, and `ctx` is stripped, never forwarded to the store.
37
44
  * @returns {any}
38
45
  */
39
46
  search(query: string, options?: Record<string, any>): any;
package/src/memory.js CHANGED
@@ -30,20 +30,31 @@ class Memory {
30
30
 
31
31
  /**
32
32
  * @param {any} content
33
- * @param {Record<string, any>} [metadata]
33
+ * @param {Record<string, any>} [metadata] - Stored alongside the content; persisted to the backend.
34
+ * @param {Record<string, any>} [opts] - Transient call options (NOT persisted). If `opts.ctx` carries a
35
+ * Loop-lent `recordMemoryOp` hook, this write is counted against that run's `result.metrics.memory.stored`
36
+ * (§3.6, opt-in — the write-side mirror of search's `recalls`). `ctx` rides here, never in `metadata`,
37
+ * precisely because `metadata` is persisted. Memory stays Loop-agnostic: it only calls an optional hook.
34
38
  * @returns {any} id
35
39
  */
36
- store(content, metadata = {}) {
40
+ store(content, metadata = {}, opts = {}) {
41
+ const ctx = opts && opts.ctx;
42
+ if (ctx && typeof ctx.recordMemoryOp === 'function') ctx.recordMemoryOp('stored');
37
43
  return this._store.store(content, metadata);
38
44
  }
39
45
 
40
46
  /**
41
47
  * @param {string} query
42
- * @param {Record<string, any>} [options]
48
+ * @param {Record<string, any>} [options] - Store search options. If `options.ctx` carries a Loop-lent
49
+ * `recordMemoryOp` hook, this recall is counted against that run's `result.metrics.memory.recalls`
50
+ * (§3.6, opt-in — the hook is absent unless the caller threads the run's ctx). Memory stays
51
+ * Loop-agnostic: it only calls an optional hook, and `ctx` is stripped, never forwarded to the store.
43
52
  * @returns {any}
44
53
  */
45
54
  search(query, options = {}) {
46
- return this._store.search(query, options);
55
+ const { ctx, ...storeOptions } = options || {};
56
+ if (ctx && typeof ctx.recordMemoryOp === 'function') ctx.recordMemoryOp('recalls');
57
+ return this._store.search(query, storeOptions);
47
58
  }
48
59
 
49
60
  /**
@@ -10,6 +10,14 @@ export type AnthropicOptions = {
10
10
  * - Model ID.
11
11
  */
12
12
  model?: string | undefined;
13
+ /**
14
+ * - API base (override for proxies/gateways; the request posts to `${baseUrl}/messages`).
15
+ */
16
+ baseUrl?: string | undefined;
17
+ /**
18
+ * - Opt-in prompt caching: send the system prompt with a `cache_control` breakpoint so Anthropic caches it. Anthropic does NOT auto-cache, so without this its cache tiers are always 0. Overridable per call via `generate(..., { cacheSystem })`.
19
+ */
20
+ cacheSystem?: boolean | undefined;
13
21
  /**
14
22
  * - Attach the full upstream response to `err.body` on HTTP errors (off by default to avoid leaking unexpected fields through error logs; `err.message` still carries the API error).
15
23
  */
@@ -22,6 +30,8 @@ export type AnthropicOptions = {
22
30
  * @typedef {object} AnthropicOptions
23
31
  * @property {string} [apiKey] - Anthropic API key (required).
24
32
  * @property {string} [model='claude-haiku-4-5-20251001'] - Model ID.
33
+ * @property {string} [baseUrl='https://api.anthropic.com/v1'] - API base (override for proxies/gateways; the request posts to `${baseUrl}/messages`).
34
+ * @property {boolean} [cacheSystem=false] - Opt-in prompt caching: send the system prompt with a `cache_control` breakpoint so Anthropic caches it. Anthropic does NOT auto-cache, so without this its cache tiers are always 0. Overridable per call via `generate(..., { cacheSystem })`.
25
35
  * @property {boolean} [exposeErrorBody=false] - Attach the full upstream response to `err.body` on HTTP errors (off by default to avoid leaking unexpected fields through error logs; `err.message` still carries the API error).
26
36
  */
27
37
  export class AnthropicProvider {
@@ -32,6 +42,8 @@ export class AnthropicProvider {
32
42
  constructor(options?: AnthropicOptions);
33
43
  apiKey: string;
34
44
  model: string;
45
+ baseUrl: string;
46
+ cacheSystem: boolean;
35
47
  exposeErrorBody: boolean;
36
48
  /**
37
49
  * Generate a response from the Anthropic API.
@@ -52,4 +64,5 @@ export class AnthropicProvider {
52
64
  * @returns {Promise<any>}
53
65
  */
54
66
  _request(body: Record<string, any>): Promise<any>;
67
+ _warnedInsecure: boolean | undefined;
55
68
  }
@@ -1,8 +1,15 @@
1
1
  'use strict';
2
2
 
3
3
  const https = require('https');
4
+ const http = require('http');
4
5
  const { ProviderError } = require('./errors');
5
6
 
7
+ /** @param {string} hostname @returns {boolean} */
8
+ function isLoopbackHost(hostname) {
9
+ const h = hostname.replace(/^\[|\]$/g, ''); // strip IPv6 brackets
10
+ return h === 'localhost' || h === '127.0.0.1' || h === '::1' || h.startsWith('127.');
11
+ }
12
+
6
13
  /** @typedef {import('../types').Message} Message */
7
14
  /** @typedef {import('../types').ToolDef} ToolDef */
8
15
  /** @typedef {import('../types').GenerateResult} GenerateResult */
@@ -11,6 +18,8 @@ const { ProviderError } = require('./errors');
11
18
  * @typedef {object} AnthropicOptions
12
19
  * @property {string} [apiKey] - Anthropic API key (required).
13
20
  * @property {string} [model='claude-haiku-4-5-20251001'] - Model ID.
21
+ * @property {string} [baseUrl='https://api.anthropic.com/v1'] - API base (override for proxies/gateways; the request posts to `${baseUrl}/messages`).
22
+ * @property {boolean} [cacheSystem=false] - Opt-in prompt caching: send the system prompt with a `cache_control` breakpoint so Anthropic caches it. Anthropic does NOT auto-cache, so without this its cache tiers are always 0. Overridable per call via `generate(..., { cacheSystem })`.
14
23
  * @property {boolean} [exposeErrorBody=false] - Attach the full upstream response to `err.body` on HTTP errors (off by default to avoid leaking unexpected fields through error logs; `err.message` still carries the API error).
15
24
  */
16
25
 
@@ -23,6 +32,11 @@ class AnthropicProvider {
23
32
  if (!options.apiKey) throw new Error('[AnthropicProvider] requires apiKey');
24
33
  this.apiKey = options.apiKey.trim();
25
34
  this.model = options.model || 'claude-haiku-4-5-20251001';
35
+ this.baseUrl = options.baseUrl || 'https://api.anthropic.com/v1';
36
+ // Opt-in prompt caching: when true, the system prompt is sent with a cache_control breakpoint so
37
+ // Anthropic caches it (unlike OpenAI/Gemini, Anthropic does NOT auto-cache — without this its
38
+ // cache_read/cache_creation tiers are always 0). Default off keeps requests byte-identical to before.
39
+ this.cacheSystem = options.cacheSystem === true;
26
40
  // See OpenAIProvider: attach full upstream body to err.body only on opt-in.
27
41
  this.exposeErrorBody = options.exposeErrorBody === true;
28
42
  }
@@ -52,6 +66,15 @@ class AnthropicProvider {
52
66
  // Override with options.system if provided
53
67
  if (options.system) system = options.system;
54
68
 
69
+ // Opt-in caching: mark a string system prompt as a cache breakpoint. Anthropic caches the prefix
70
+ // up to this point (min ~1024-4096 tok depending on model; a shorter prompt silently won't cache —
71
+ // harmless). A per-call options.cacheSystem can override the instance default. Arrays are passed
72
+ // through untouched (the caller already shaped their own cache_control blocks).
73
+ const cacheSystem = options.cacheSystem != null ? options.cacheSystem === true : this.cacheSystem;
74
+ if (cacheSystem && typeof system === 'string' && system.length > 0) {
75
+ system = [{ type: 'text', text: system, cache_control: { type: 'ephemeral' } }];
76
+ }
77
+
55
78
  /** @type {Record<string, any>} */
56
79
  const body = {
57
80
  model: this.model,
@@ -84,9 +107,13 @@ class AnthropicProvider {
84
107
  text,
85
108
  toolCalls,
86
109
  model: data.model || this.model,
110
+ // Anthropic's `input_tokens` is ALREADY the uncached remainder (cached tokens are reported
111
+ // separately, not folded in — verified live), so no subtraction here, unlike OpenAI/Gemini.
87
112
  usage: {
88
113
  inputTokens: data.usage?.input_tokens || 0,
89
114
  outputTokens: data.usage?.output_tokens || 0,
115
+ cacheReadTokens: data.usage?.cache_read_input_tokens || 0,
116
+ cacheCreationTokens: data.usage?.cache_creation_input_tokens || 0,
90
117
  },
91
118
  };
92
119
  }
@@ -134,7 +161,15 @@ class AnthropicProvider {
134
161
  _request(body) {
135
162
  return new Promise((resolve, reject) => {
136
163
  const payload = JSON.stringify(body);
137
- const req = https.request('https://api.anthropic.com/v1/messages', {
164
+ const url = new URL(this.baseUrl + '/messages');
165
+ const transport = url.protocol === 'https:' ? https : http;
166
+ // Plaintext key to a remote host exposes it on the wire; loopback (test servers / local
167
+ // proxies) is the legitimate http case. Warn once, mirror OpenAIProvider.
168
+ if (url.protocol === 'http:' && !isLoopbackHost(url.hostname) && !this._warnedInsecure) {
169
+ this._warnedInsecure = true;
170
+ console.warn(`[AnthropicProvider] sending x-api-key over PLAINTEXT http to ${url.hostname} — key exposed on the wire. Use https.`);
171
+ }
172
+ const req = transport.request(url, {
138
173
  method: 'POST',
139
174
  headers: {
140
175
  'Content-Type': 'application/json',