export type Provider = import("../types").Provider; export type Message = import("../types").Message; export type ToolDef = import("../types").ToolDef; export type ToolCall = import("../types").ToolCall; export type Usage = import("../types").Usage; export type RunMetrics = import("../types").RunMetrics; export type GenerateResult = import("../types").GenerateResult; export type Store = import("../types").Store; export type Checkpoint = import("./checkpoint").Checkpoint; export type Retry = import("./retry").Retry; export type Stream = import("./stream").Stream; export type LoopOptions = { provider: Provider; system?: string | undefined; checkpoint?: import("./checkpoint").Checkpoint | undefined; retry?: import("./retry").Retry | undefined; stream?: import("./stream").Stream | undefined; store?: import("../types").Store | undefined; onToolCall?: Function | undefined; onText?: Function | undefined; onError?: Function | undefined; throwOnError?: boolean | undefined; policy?: Function | undefined; /** * - async (msgs, ctx) => msgs. Context-assembly chokepoint: shape the * window sent to the provider each round (e.g. a context-engineering library). Returns a VIEW — the * canonical transcript is never mutated. Fail-open (a thrown error degrades to full context); a * thrown HaltError propagates. `ctx` is the per-run opaque blob (`run(msgs, tools, { ctx })`), the * same object forwarded to `policy`; litectx reads `ctx.task` (intent) and `ctx.budget`. The * neutral-unit signature `assemble(units, ctx)` is provided by bareagent's msgs⇄units adapter * (src/context-units.js), which composes over this msgs-level seam. When `ctx` is an object, the * Loop also lends a provider-bound `ctx.summarize(excerpt, opts?) => Promise` (R-C6, * non-enumerable): assemble calls it to roll a summary window — bareagent makes the one model * call, the consumer owns the trigger/N/splice. Its usage is forwarded to `onLlmResult` so the * summary tokens count against the budget. */ assemble?: Function | undefined; /** * - async (msgs, ctx) => msgs. DESTRUCTIVE transcript-trim chokepoint (RT-2), * the opposite of `assemble`: it BOUNDS the canonical transcript — the Loop replaces `msgs` with what * this returns, evicting old turns AFTER they are harvested. Runs once per round before `assemble`. * So eviction never drops un-persisted history, wire it via `unitTrimmer({ trim, onHarvest, policy })` * (src/context-units.js), which performs the harvest-before-evict interlock over litectx's `trim` verb. * An optional `.flush(msgs, ctx)` method is called on clean completion for the residual-window harvest. * Fail-open (a trim fault degrades to no eviction that round); a thrown HaltError propagates. */ trim?: Function | undefined; /** * - async (event) => void after each LLM call; forwards usage to * gate.record (via wireGate). `event.kind` discriminates the source: `'turn'` for a main-loop round, * `'summarize'` for an out-of-band `ctx.summarize` call (R-C6). Both count against the budget. Each * event also carries `rateSource` ('provider'|'caller'|'tier'|'default'|null, BA-21) beside the * unchanged two-value `pricing` — a 'tier'/'default' cost is a flagged guesstimate, never a silent * guess ('tier' = recognized Claude tier, 'default' = blind ceiling fallback). */ onLlmResult?: Function | undefined; /** * - BA-21: * per-1K USD rates for THIS run's model (`in`/`out` required, finite, non-negative; cache multipliers * default to Anthropic's 0.1×/1.25×). When set, every token-only round is priced from these * (rateSource:'caller') instead of the built-in guesstimate. There is NO per-model rate table — bring * your own rate, or take a flagged guesstimate (haiku/sonnet recognized, else the Sonnet-tier default). */ rates?: { in: number; out: number; cacheReadMult?: number; cacheWriteMult?: number; } | undefined; onToolResult?: Function | undefined; /** * - BA-12 safety net (default 3). Short-circuit the run when a * tool's `execute` throws this many times IN A ROW for a BYTE-IDENTICAL call (same tool + same args). A * tool error is deliberately fed back to the model so it can recover — that is the point of the feedback * loop — but a model re-issuing the SAME impossible call verbatim can never succeed, and spins to the * budget cap with no progress (observed live: `claude-sonnet-5` retried a rejected write 8/8 times). * Deliberately the NARROWEST guard: any tool call that SUCCEEDS, or the same tool called with DIFFERENT * arguments, resets the streak — a model adapting its input in response to an error is genuinely * recovering and is never penalised. Returns cleanly with `error: 'stuck:'` (mirrors the deny/halt * returns; never throws even under `throwOnError`; transcript sealed; the model's text preserved). * `0`/`Infinity` disables (restores pre-BA-12 behavior: errors are advisory forever). */ maxIdenticalToolErrors?: number | undefined; /** * - BA-11 safety net (default 3). Short-circuit the run when * `policy` denies this many tool calls IN A ROW with no allowed call in between — a governance deny is * not a recoverable tool error, so a model that keeps retrying variants of a denied action would * otherwise burn the budget to the cap without progress (probe-16: 16 calls, sensor never reached). Any * tool call that PASSES policy resets the streak, preserving allowlist-safe pivoting (deny X → allow Y). * The run returns cleanly with `error: 'denied:'` (mirrors the halt return; never throws even under * throwOnError). Set `0` or `Infinity` to disable (restores pre-BA-11 advisory-deny behavior). */ maxConsecutiveDenials?: number | undefined; /** * - Removed in v0.8; presence throws a migration error. */ maxRounds?: number | undefined; }; export class Loop { /** * `policy` is async `(toolName, args, ctx) => true | string`. Recommended wiring: a closure * that delegates to a bareguard Gate (`require('bare-agent/bareguard').wireGate(gate).policy`). * Anything other than `true` denies; a string is fed to the LLM verbatim as the deny reason. * A throw of `HaltError` exits the loop cleanly. `onLlmResult`/`onToolResult` forward usage and * tool outcomes to `gate.record` (via wireGate) and never kill the loop on error. * @param {LoopOptions} options * @throws {Error} `[Loop] requires a provider` — when options.provider is missing. * @when you are running a model think/act/observe cycle and need round accounting, tool dispatch, spin guards, and a governance chokepoint * @fails returns the last assistant text with an `error` tag on any bound (halt/deny-streak/truncation/provider error); throws only if constructed without a provider. A policy HaltError exits clean. * @example * const loop = new Loop({ provider, policy }); * const { text, error, metrics } = await loop.run(messages, tools); * if (error) handle(error); */ constructor(options?: LoopOptions); provider: import("../types").Provider; system: string | null; checkpoint: import("./checkpoint").Checkpoint | null; retry: import("./retry").Retry | null; stream: import("./stream").Stream | null; onToolCall: Function | null; onText: Function | null; onError: Function | null; throwOnError: boolean; store: import("../types").Store | null; policy: Function | null; maxConsecutiveDenials: number; maxIdenticalToolErrors: number; assemble: Function | null; trim: Function | null; onLlmResult: Function | null; onToolResult: Function | null; rates: { in: number; out: number; cacheReadMult?: number; cacheWriteMult?: number; } | null; _stopped: boolean; /** @type {Message[]} */ _history: Message[]; /** * @param {string} source * @param {any} err * @param {Record} [extra] */ _reportError(source: string, err: any, extra?: Record): void; /** @param {string|null} model @param {string} rateSource - 'tier' | 'default' */ _warnGuesstimateOnce(model: string | null, rateSource: string): void; _warnedGuesstimate: boolean | undefined; /** @param {{type: string, data?: any, ts?: string}} event */ _safeEmit(event: { type: string; data?: any; ts?: string; }): void; /** * @param {string} name * @param {Function|null} fn * @param {...any} args */ _safeCall(name: string, fn: Function | null, ...args: any[]): void; /** * Run the think/act/observe loop. * @param {Message[]} messages - Conversation messages in OpenAI format. * @param {ToolDef[] | (() => ToolDef[])} [tools=[]] - Tool definitions, or a thunk returning them. A * thunk is re-evaluated each round (D4/eval-assist F2) so a tool set that grows mid-run — e.g. a skill * unlocking its tools — is offered on the next round; a static array is resolved once at wire time. * @param {Record} [options={}] - Per-run overrides (system, temperature, ctx, etc.). * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, model: string|null, msgs: Message[], metrics: RunMetrics, malformedToolCall?: GenerateResult['malformedToolCall'], temperatureDropped?: boolean, thinkingApplied?: boolean|null, thinkingStatus?: 'applied'|'unsupported'|'ignored'|'unknown'}>} * On halt the returned `error` is `halt:` (or `halt:unknown` if the * thrown HaltError carried no `rule`), and `msgs` is sanitized so any * dangling assistant `tool_calls` from the halted round are paired with * synthetic `[halted]` tool replies — safe to feed back into another * provider call without violating OpenAI's tool-call/tool-result pairing. * * BA-5 — a bound that fires PRESERVES the model's work. Every terminating path (governance halt, * deny-streak, provider error under `throwOnError:false`, `stop()`, the hard round limit) returns the * last non-empty assistant text in `text` rather than substituting `''`. A bound firing is normal * termination for a bounded attempt, and that text is the only channel from attempt N to attempt N+1 — * the caller decides what a partial result is worth. `error` remains the sole success signal: a * non-empty `text` NEVER means the run converged, so never infer success from it. `text` stays `''` * when the model genuinely produced none (no placeholder is invented). * * A caller-initiated `stop()` returns `error: null` — a deliberate stop is not a fault. (It previously * fell through to the hard-round-limit return and reported that safety warning as its `error`.) * * BA-13 — `stopReason` (the round's NEUTRAL stop reason) is surfaced on EVERY return, and non-clean * terminal rounds are error-tagged instead of laundered into `error: null`: a safety `refusal` returns * `error: 'refusal'` and a `context_exceeded` returns `error: 'context_exceeded'`, both with partial * text preserved (BA-5). BEHAVIOR CHANGE: a refused round that previously returned `error: null` with * empty text now returns `error: 'refusal'` — the point of the fix. `pause_turn` is NOT terminal: the * loop resumes (bounded by the hard round limit / gate). `error` stays the sole success signal. * @throws {Error} `[Loop] Tool is missing a name` — when a tool has no name or a non-string name. * @throws {Error} `[Loop] Tool "X" is missing an execute() function` — when execute is not a function. * @throws {Error} `[Loop] Tool "X" has invalid parameters` — when parameters is not an object. */ run(messages: Message[], tools?: ToolDef[] | (() => ToolDef[]), options?: Record): Promise<{ text: string; toolCalls: ToolCall[]; usage: Usage; cost: number; error: string | null; stopReason: string | null; model: string | null; msgs: Message[]; metrics: RunMetrics; malformedToolCall?: GenerateResult["malformedToolCall"]; temperatureDropped?: boolean; thinkingApplied?: boolean | null; thinkingStatus?: "applied" | "unsupported" | "ignored" | "unknown"; }>; _warnedTruncated: boolean | undefined; /** * Health check — validates provider, store, and tools without throwing. * @param {ToolDef[]} [tools=[]] - Tool definitions to validate. * @returns {Promise<{provider: {ok: boolean, error?: string}, store: {ok: boolean, error?: string, skipped: boolean}, tools: {ok: boolean, errors?: string[]}}>} * Never throws — all failures captured in return value. */ validate(tools?: ToolDef[]): Promise<{ provider: { ok: boolean; error?: string; }; store: { ok: boolean; error?: string; skipped: boolean; }; tools: { ok: boolean; errors?: string[]; }; }>; /** * Stateful single-turn chat that maintains conversation history across calls. * @param {string} text - User message. * @param {ToolDef[]} [tools=[]] - Tool definitions. * @param {Record} [options={}] - Per-run overrides. * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, model: string|null, msgs: Message[], metrics: RunMetrics, malformedToolCall?: GenerateResult['malformedToolCall'], temperatureDropped?: boolean, thinkingApplied?: boolean|null, thinkingStatus?: 'applied'|'unsupported'|'ignored'|'unknown'}>} */ chat(text: string, tools?: ToolDef[], options?: Record): Promise<{ text: string; toolCalls: ToolCall[]; usage: Usage; cost: number; error: string | null; stopReason: string | null; model: string | null; msgs: Message[]; metrics: RunMetrics; malformedToolCall?: GenerateResult["malformedToolCall"]; temperatureDropped?: boolean; thinkingApplied?: boolean | null; thinkingStatus?: "applied" | "unsupported" | "ignored" | "unknown"; }>; /** * Request a clean stop. The current tool call finishes; the loop then exits at the next boundary and * `run()` resolves with `error: null` and the last text the model produced (BA-5) — a deliberate stop * is not a fault, and callers should not need a `stoppedByBound` flag to un-lie the return value. */ stop(): void; } /** * Estimate the USD cost of one round's usage, pricing the FOUR token tiers separately (D9/L7): * uncached input, output, cache-read, and cache-creation. Folding cache tokens into the full input * rate mis-prices badly — a warm prompt is mostly cache-read (~0.1–0.5× input) and Anthropic's * cache-creation is a ~1.25× premium — so each tier gets its own rate. The rate comes from * `resolveRates` (caller `rates` → recognized tier → Sonnet-tier ceiling default), so a token-bearing * round is ALWAYS priced (guesstimate-and-run per BA-21) — a null model no longer forces `unpriced`. * Returns null ONLY when usage is absent, or the arithmetic is non-finite (runaway ±Infinity), the * genuinely-unpriceable cases that must stay `unpriced` / fail-closed. * @param {string|null} model * @param {Usage|null} usage * @param {{in: number, out: number, cacheReadMult?: number, cacheWriteMult?: number}|null} [callerRates] * @returns {number|null} */ export function estimateCost(model: string | null, usage: Usage | null, callerRates?: { in: number; out: number; cacheReadMult?: number; cacheWriteMult?: number; } | null): number | null; /** * Resolve the effective per-1K rate for a round, and WHERE it came from. Caller-supplied `rates` win * (authoritative to the caller, `'caller'`); else a recognized Claude tier matched by the model id * (`'tier'` — a confident but non-caller-vouched guess); else the Sonnet-tier ceiling fallback * (`'default'` — a blind guess for an unknown/absent model). A null model still resolves to the * ceiling — we guesstimate and run, never refuse. `'tier'` and `'default'` are BOTH built-in * guesstimates (see `isGuesstimateSource`); the split only lets a consumer tell a recognized-tier * price from a blind ceiling in its own ledger (BA-21 follow-up, requested by the bareloop adopter). * @param {string|null|undefined} model * @param {{in: number, out: number, cacheReadMult?: number, cacheWriteMult?: number}|null} [callerRates] * @returns {{rates: {in: number, out: number, cacheReadMult?: number, cacheWriteMult?: number}, source: 'caller'|'tier'|'default'}} */ export function resolveRates(model: string | null | undefined, callerRates?: { in: number; out: number; cacheReadMult?: number; cacheWriteMult?: number; } | null): { rates: { in: number; out: number; cacheReadMult?: number; cacheWriteMult?: number; }; source: "caller" | "tier" | "default"; }; /** * Resolve the priced USD for a round AND its `rateSource`. A provider MAY report its own authoritative * `costUsd` on the GenerateResult (e.g. CLIPipeProvider surfacing the claude CLI's `total_cost_usd` — a * real price, `source:'provider'`). When present as a FINITE number it wins over any estimate — * including `0`, a valid priced value (a subscription/marginal-$0 run), which stays priced. A non-finite * provider cost (±Inf/NaN) is NOT a price → fall through to the rate-based estimate (`caller`/`default`). * `source` is null only when the round is genuinely unpriced (no usage, or a non-finite estimate). * A rate-estimated round is `'caller'` (caller rates), `'tier'` (recognized Claude tier), or `'default'` * (blind ceiling fallback) — the last two are both built-in guesstimates (`isGuesstimateSource`). * @param {any} result - the GenerateResult from provider.generate() * @param {string|null} model * @param {Usage|null} usage * @param {{in: number, out: number, cacheReadMult?: number, cacheWriteMult?: number}|null} [callerRates] * @returns {{cost: number|null, source: 'provider'|'caller'|'tier'|'default'|null}} */ export function resolveRoundCost(result: any, model: string | null, usage: Usage | null, callerRates?: { in: number; out: number; cacheReadMult?: number; cacheWriteMult?: number; } | null): { cost: number | null; source: "provider" | "caller" | "tier" | "default" | null; };