loopctl 0.3.0

A trait-based framework for building agent loops with pluggable LLM clients, tools, and memory
Documentation
# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/2.0.0.html).

## [Unreleased]

## [0.3.0] - 2026-08-30

### Added

- **MCP client (`mcp` feature, `loopctl::mcp`)** — adapt any MCP server's tools as loopctl `Tool` implementations. New public types: `McpClient` (a connected client handle), `McpToolProvider` (discovers a server's tools and registers them into a `ToolRegistry`), `McpTool` (one server tool as a `Tool`), `McpError`. Real-server transports: `McpClient::stdio(command)` spawns a server as a child process over stdio; `McpClient::http_sse(endpoint)` (or `http_sse_with_client(endpoint, reqwest::Client)`) connects via the Streamable HTTP transport (`Mcp-Session-Id`, JSON-vs-SSE response splitting, and DELETE-on-close handled by rmcp); `McpClient::reconnect(&StreamRetryConfig)` re-establishes a dropped connection using the crate's existing backoff strategy. New public `CommandSpec` describes what to spawn; `McpClient::in_process` connects an in-process rmcp server for tests and bundled-server use. `McpToolProvider::with_call_timeout(Duration)` bounds every `tools/call` round-trip (default 60s) — a call over budget resolves to a soft error result naming the tool and the budget, so a wedged MCP server costs one tool result instead of hanging the loop. The optional `rmcp` dependency is pulled only by `mcp` (which also enables `streaming` and `reqwest`); `default = []` unchanged. Runnable demos: `examples/mcp-adapter.rs`, `examples/mcp-stdio-server.rs`.
- **MCP server adapter (`McpServerAdapter`)** — serve a loopctl `ToolRegistry` over MCP (stdio), consumable by any MCP client. `McpServerAdapter::new(registry, ctx, name, version)` + `serve_stdio` implements `ServerHandler` (`list_tools` → `all_schemas`, `call_tool` → `Tool::call`); `serve(impl IntoTransport)` is transport-agnostic for future HTTP/SSE. Served calls are raced against the request's cancellation token (client cancel or disconnect drops the in-flight tool future — tools must be cancellation-safe, the same contract the engine imposes) so a wedged tool no longer leaks a task per call. `list_tools` forwards `is_read_only` as `annotations.readOnlyHint`; tools whose input schema does not compile as JSON Schema — malformed keywords, uncompilable regexes, dangling or external `$ref`s (external references are refused, never fetched) — or is not object-typed are omitted with a warning rather than advertised with a schema strict clients may reject; unknown names return `METHOD_NOT_FOUND` listing the registered names; empty descriptions are omitted. Tool names are forwarded verbatim (the spec recommends `^[a-zA-Z0-9_-]{1,64}$`; conforming names are the embedding application's responsibility). Example: `examples/mcp_server.rs`.
- **`#[derive(Tool)]`** — the new `loopctl-derive` crate (in-repo at `derive/`, the first companion crate) generates the full `impl Tool` from a `Deserialize` input struct: the name (container override or snake_cased ident), the description (override or the struct's doc comment — one of the two is required), a statically emitted JSON Schema built from the fields with the Rust-type → JSON-Schema-type map (`String`→string, integers→integer, floats→number, `bool`→boolean, `Vec<T>`→array recursion, string-keyed maps→object with `additionalProperties`; `Option<T>` and `#[serde(default)]` fields leave `required`), `additionalProperties: false` unless `#[tool(allow_extra)]`, and a `call` that deserializes the incoming `Value` (serde failures map to `ToolError::InvalidInput`) and dispatches to an inherent `async fn run(&self, input, ctx)` — renameable via `#[tool(handler = "…")]`. Provided-method overrides (`read_only`, `concurrency_safe`, `system_prompt`) emit only when their attribute is present; `#[tool(skip)]` drops a field from the schema (requires `Option` or `#[serde(default)]`); `#[tool(default)]` or `#[serde(default)]` omit it from `required`; field renames mirror serde's `rename` — set `#[tool(name = "…")]` to match. The generated future owns a clone of the `ToolContext` (the trait bounds the boxed future by `self`'s lifetime alone). Reached via the new `derive` feature — `use loopctl::Tool;` brings the trait (re-exported at the crate root, additive) and the derive together. The repo is now a Cargo workspace (`members = ["derive"]`); `default = []` is unchanged. Diagnostics are spanned `syn::Error`s (never panics).
- **`BedrockClient`** (feature `bedrock`) — the native AWS Bedrock provider, SigV4-authenticated (`hmac`/`sha2`, no AWS SDK tree). Two invoke paths selected per request by model-id prefix or `BedrockPath` override: `anthropic.*` models use the native Anthropic Messages body (reusing the direct-API body builders, system folding, and the shared stream emitter), everything else uses Bedrock's cross-model Converse API with its own body/event shapes (typed content blocks, `toolConfig`/`toolSpec`, `toolUse`/`toolResult` mapping, and a Converse-stream emitter covering text, tool-input, and reasoning lanes). Model ids are percent-encoded into the URI path; the SigV4 canonical request carries that path re-encoded per segment (a `%3A` in a versioned model id signs as `%253A`) — the non-S3 convention, verified against the live Bedrock runtime. Streaming responses arrive as AWS binary event-stream frames (`application/vnd.amazon.eventstream`) — decoded by a hand-written incremental parser (exception frames carry their `:exception-type` into the error) into the standard `StreamEvent` sequence; non-streaming responses are plain JSON with `toolUse` block extraction. Both `stream_messages` and `create_message` implemented; `from_env()` reads the standard `AWS_*` variables. The SigV4 credential triple can be swapped atomically on a live client via `set_credentials` (STS/IRSA rotation — each request signs with one consistent snapshot, never a torn pair), and the output-token budget is configurable per client via the builder's `max_tokens` (defaulting to the engine-wide constant) for models whose own cap is lower.
- Two new provider profiles over the existing `OpenAiClient`: `provider::azure(resource)` (feature `azure`) targets Azure OpenAI's v1 API — `https://{resource}.openai.azure.com/openai/v1`, standard Bearer auth, `AZURE_OPENAI_API_KEY` and `AZURE_OPENAI_MODEL` (both required — the model is the deployment name configured in the resource); the legacy deployment-URL scheme is deliberately unsupported. `provider::moonshot()` (feature `moonshot`) targets Moonshot AI's OpenAI-compatible API — `MOONSHOT_API_KEY`, optional `MOONSHOT_MODEL` (default `kimi-k2-0905-preview`). Both follow the established `deepseek`/`grok`/`zai` env-profile pattern: one function per provider, zero new wire code.
- **`redaction` feature + `RedactingMiddleware`/`SecretPatternSet`/`SecretPattern`** (`loopctl::middleware`) — scrub secrets from tool output post-execution: a configurable set of named regex patterns (curated defaults cover `Authorization: Bearer` headers, `api_key=`-style tokens, AWS access-key IDs, PEM private-key blocks, GitHub/GitLab PATs) plus a Shannon-entropy heuristic (≥32-char tokens at ≥4.5 bits/byte become `[REDACTED:high_entropy]` — hex hashes stay visible at their 4.0 ceiling). Every match becomes `[REDACTED:<kind>]`; image parts and non-text content pass through untouched; `is_error` and `DisplayHint` are preserved (advisory only — loop semantics unaffected). Extend with `SecretPatternSet::with_pattern`, disable the heuristic with `with_entropy_heuristic(false)`. Pulls `regex` only under the feature; `default = []` unchanged.
- **`SafetyShieldMiddleware`** (`loopctl::middleware`, `tool_shield` feature) — the missing wire for the tool-safety shield: consults the configured `ToolSafetyShield` before every watched dispatch, turns a `Block` decision into a soft error naming the shield's reason (the model sees the refusal and adapts; the run continues), emits a `Warn` decision's reason and category as a tracing warning while the call proceeds, and feeds every admitted call (executed or served from an inner cache) back via `record_invocation` so multi-call combination rules (`curl` then `| sh`) fire across turns. Provides the `ShieldContext::recent_calls` snapshot the field always documented — the most recent 20 dispatched `(tool, turn)` pairs, bounded to constant cost over a long session. Installation order matters and is documented: install it inside (registered after) any middleware that rewrites `ctx.tool_name` so aliases are evaluated under the name the registry executes, and outside `MemoizingMiddleware` so cache hits are still evaluated and recorded (the repetition dimension scores the model's behavior, not the tool's execution). Repeated shield refusals count as failures toward the tool's circuit breaker. Opt-in via pipeline installation — a default loop is unchanged.
- **The engine consults the tool-health breaker before dispatch**: with a `ToolHealthRegistry` configured, a tool whose circuit breaker is open is refused with a soft error ("tool temporarily unavailable: circuit breaker open") instead of executing — the model sees the refusal and the run continues. The gate grants through the breaker's mutating `allow_request`, so it enforces exactly the breaker's decision (a closed breaker dispatches even when the health score is poor — the score never blocks, only the breaker does) and keeps recovery single-flight (an expired cooldown grants one probe; concurrent calls during the probe are refused). A new registry-level `ToolHealthRegistry::allow_request` mirrors the breaker method for the gate. The gate keys on the *requested* name — renaming happens inside the pipeline, so a host pipeline that renames diverges the gate's key from the resolved-name key health recording uses; the two key spaces are identical for every non-renaming pipeline, and no in-tree middleware renames.
- **`CompactionContext::instructions` + `CompactionContext::additional_context`** — pre-compact hook guidance threaded to the compactor: the driver keeps the hooks' merged `new_instructions`/`additional_context` (previously only the abort bit was read) and `ContextManager::compact_with_reason` takes them, so LLM summarizers can consume the hook's guidance. The pre-compact hook context's `trigger` is derived from the compaction reason (`Manual` reaches hooks as `Manual`) instead of hardcoded `Auto`, and the executor chains hooks: a later hook's `custom_instructions` carries the accumulated instructions of earlier hooks, as the field always documented. (**Breaking:** `compact_with_reason` takes new parameters — see the window-accounting entry under Changed.)
- **`CircuitBreakerConfig::probe_timeout`** (+ `ToolCircuitBreaker::with_probe_timeout`) — how long the single `HalfOpen` probe may stay in flight before the breaker gives up on it. A probe whose result never arrives (cancelled dispatch, dead task) previously stranded the breaker in `HalfOpen` forever; after the timeout `allow_request` re-arms the `Open` cooldown and a later request probes again. Defaults to 30s. The probe is lease-like: availability queries treat an expired probe as re-armed, a result arriving after expiry re-arms recovery instead of closing, a call arriving after the expiry-anchored cooldown grants a fresh probe in the same call, `0` disables the lease, and the registry's availability reads, `would_be_half_open`, and `state_label` are all expiry-aware.
- **`RequestOptions::model` + `RequestOptions::with_model(.)`** — a per-request model override honored by all three providers (OpenAI and Anthropic replace the request body's `model` field; Gemini, which carries the model in the URL path, builds the URL from it). The client's own model is untouched — concurrent loops over one shared client cannot cross-wire their models — and requests without an override are byte-identical to before. An empty or whitespace-only name is ignored (the override stays unset), mirroring the client builders' rejection of nameless models. This is the seam the fallback breaker routes through (see the fallback entry under Changed).
- **`LoopMachine::set_context_tokens(tokens)` and `LoopMachine::compaction_noop(tokens_before, tokens_after)`** — the two driver-fed context-estimate feeds. The driver measures the payload the provider would receive (`count_context`, preferring the manager's counter, plus the measured per-request overhead) whenever it grows outside a model response — after `accept_input` at run start, after `tool_results` in the dispatch path, and before a model call carrying fresh transients — so the compaction trigger sees the true size of the next request. `compaction_noop` is the feed for passes that changed nothing (no compactor ran, a pre-compact hook vetoed, or the compactor returned the conversation unchanged): it leaves the committed history and the pending buffer untouched and adopts `tokens_after` as the current context size.
- **Detection read-side additions:** `LoopDetector::max_operation_count` (the live maximum occurrence count in the window *below* the loop threshold, so streak telemetry can watch a potential loop build before it trips) and `DetectionManager::check_loop_pattern` / `check_convergence_pattern` (the loop-only / convergence-only pattern as pure queries over recorded state — dispatch pre-checks use the loop one, so a step examined twice is counted once; both respect their `enable_*` flags).
- **`DetectionManager::consume_pending_loop_stop`** — the engine calls it at every run end (clean and failing paths): when the loop window holds a pattern at or over `stop_threshold` that the run never fired, the window is cleared, mirroring the consumption a fired stop performs — otherwise the *next* run's first dispatch is killed by repetitions it never produced. Relatedly, `stop_threshold: 0` now disables hard stops in the engine's decision too, matching the detector's rule and the field doc (previously the "disabled" configuration stopped at the *first* detection — earlier than any enabled value).
- **`ToolRegistry::all_tools()`** — every registered tool as `Vec<&dyn Tool>`, in registration order (an overwriting same-name registration keeps the tool's original position). Includes concurrency-unsafe tools, unlike `concurrent_safe_tools()` — hosts walking the registry (a system-prompt builder collecting each tool's prompt section) no longer need the `tool_names()` + `get()` N-lookup workaround.
- **`RunConfig::with_parallel_dispatch(ParallelDispatchConfig)`** — the one way a host opts a run into parallel tool dispatch: `RunConfig` is `#[non_exhaustive]` and previously exposed no setter, so `ParallelMode::Parallel` (implemented, documented, defaulted off) was unreachable from outside the crate.
- **`ToolOutput::with_payload(ToolContent)`** — fluent payload replacement on the `#[non_exhaustive]` `ToolOutput`, making multipart outputs constructible outside the crate.
- **`ToolPostContext` gains a `tool_call_id` field**, mirroring `ToolPreContext`, so observers can pair pre/post events exactly — including same-tool retries and parallel calls where the `(turn, tool)` pair is ambiguous.
- **`MockApiClient` implements the `*_with_options` methods**: a per-request `model` override is honored on the streaming path (the response's `MessageStart` names the routed model — the mock's stand-in for the wire-level switch, so fallback-routing tests observe the routing), a `response_format` is accepted by serving the canned response unchanged (the mock cannot enforce a schema, so the canned text *is* the structured answer — `request_structured` flows are testable against the mock without a provider), and `tool_constraint` is still rejected loudly.
- **`loopctl::testing::EnvGuard`** — a shared RAII guard for tests that mutate environment variables: `EnvGuard::acquire(&["VAR_A", "VAR_B"])` takes a crate-wide lock (parallel tests cannot interleave environment access), snapshots the named variables, and restores each to its prior value — present or absent — on drop. Usable from lib tests, integration tests, and downstream via the `testing` feature.

### Changed

- **Breaking (engine signatures, from the `BareLoop` decomposition):** machine turn indices are now 0-indexed (`CallLLM { turn: 0 }` for the first turn; `AwaitingModel`/`AwaitingTools` follow); `MachineStep::CallTools` gains a `turn` field; `RunConfig` gains a `memory_top_k` field (configurable memory entries retrieved per turn, default 3 — was a hardcoded magic number); `LoopError` gains a `ToolRecoveryExhausted { tool, attempts }` variant (the driver now enforces `MAX_RECOVERY_ATTEMPTS` (5) as a hard ceiling — a recovery strategy that always returns `Retry` is stopped after 5 retries instead of looping forever). Migration: exhaustive `LoopError` matches add the arm; `RunConfig` struct literals add the field (`..Default::default()` or the builders); pattern matches on step variants adjust.
- **Breaking: `StreamEvent::PartStop` is now a struct variant carrying `index: Option<usize>`** naming the lane being closed, and `StreamAccumulator` routing is kind-aware: tool-argument deltas (`InputJson`/`ToolCall`) only enter tool slots, text deltas only text slots, thinking deltas only thinking slots (a `PartStart` carrying `part: None` opens a thinking lane; all three first-party emitters follow this convention, their text lanes carrying `Some(text(""))`). The accumulator closes the open slot carrying that index, falling back to the legacy oldest-open-slot (FIFO) behavior when the index is `None`. The OpenAI emitter closes an open content lane before the first tool `PartStart` and addresses every stop. Migration: replace `StreamEvent::PartStop` constructions with `StreamEvent::PartStop { index: None }` (or the lane's index when known) and adjust patterns; events serialized before the index existed still deserialize (`#[serde(default)]`).
- **Behavior change (window accounting):** the context estimate counts the whole request payload, not just history. The session system prompt and the advertised tool schemas are measured once per loop and added to every estimate feed, and a turn's transient messages — context contributors, retrieved memories — are measured before the request goes out; if the payload then crosses the threshold or the emergency line, the turn defers to compaction instead of serving it unchecked (a deferred turn has not fired its observer events; the retried turn re-consults contributors against the compacted history). Previously every window check was history-scoped: a request could exceed the provider's window by exactly the uncounted overhead, and a chunky contributor or memory injection rode entirely outside the check. Hosts measuring served requests take note: counting only `StreamRequest::messages` now understates the payload. The compaction serving a deferred turn reserves the transient budget: `ContextManager::compact_with_reason` takes a `reserved_tokens` parameter (**breaking**; `compact_manual` passes `0`), subtracted from both the compaction target and the fit check. A reserved-fit failure reports the payload including the reserve, and the engine logs the compactor's own error before mapping the failure to `ContextExceeded`, so a broken summarizer no longer masquerades as a bare overflow.
- **Breaking: `LoopMachine::compaction_result` now takes `(compacted, tokens_before, tokens_after)`** — the compacted history plus the driver's measured full-history size ahead of the pass and after it, replacing the estimate the machine used to record at compaction-request time (the `last_compaction_tokens` field is gone; serialized checkpoints change accordingly). Both compaction feeds compare the driver's measured pre-pass and post-pass counts: when nothing was shaved off, the machine terminates the run with `ContextExceeded` instead of trusting a stale estimate. Migration: pass the two measurements from the same counter the driver uses for its context estimate.
- **Behavior change (default compaction is real):** `BareLoop` constructors seed a default `ContextManager` (a `TruncatingCompactor` with window and threshold synced from the session config) when the supplied manager bundle carries none (`new`, `new_with_managers`, `from_machine`), and `ConstrainedProfile::apply` attaches one synced from the loop's session config — the profile's "aggressive context budgeting" is enforced machinery rather than a marketing claim. Default-configured loops with `auto_compact` on now actually compact at the threshold (observer-visible via `on_compaction`) instead of growing unbounded; a compaction pass that cannot reduce the history terminates the run with a typed `LoopError::ContextExceeded` rather than silently resetting the estimate. `auto_compact: false` still disables threshold compaction; hosts installing their own `ContextManager` are unaffected.
- **`EnsureContextResult::NoAction` classification:** `ContextManager::compact_with_reason` (and `compact_manual`/`ensure_context_fits`) reports a successful pass that shrinks neither the message list nor the token count as `NoAction` instead of `Compacted`. Classification and the returned token fields use the **manager's configured counter**, not the compactor's self-report (`tokens_after`/`tokens_saved` normalized to the manager's measurements; the overflow check re-counts the result with the same counter). `on_compaction` observers and post-compact hooks stay silent for no-action passes. Migration: code that treated any `Ok(Compacted(..))` as "the messages may have changed" should use `into_messages` — the returned list is identical under `NoAction`.
- **Behavior change (truncation strictness):** a stream that ends without the provider's terminal signal is no longer a completed turn. The emitters no longer synthesize a terminal `MessageStop` on a bare EOF (Anthropic emits the stop only for a real `message_stop` event, Gemini only after a `finishReason` chunk, OpenAI only after a `finish_reason` or the `[DONE]` sentinel), and `StreamHandler::stream_turn` treats end-of-stream without `MessageStop` as a *transient* truncation failure routed through the retry ladder — retried within budget, handed the last-chance non-streaming request at exhaustion with the fallback enabled, otherwise failing with `InitFailed` naming the truncation. Previously a connection cut mid-generation (clean FIN behind a proxy) was normalized into an apparently well-formed turn: truncated text returned as completed output and the breaker credited with a success. Custom `ApiClient` implementations that end streams without `MessageStop` will now see retries followed by a typed failure; emit the terminal event (as every first-party emitter and `MockApiClient` does).
- **Behavior change: permanent client errors are no longer retried.** `ApiError::is_retryable` names an explicit retryable set — 5xx server errors, 408 request timeouts, rate-limit responses (429/503/529), connection-level transport failures, interrupted SSE streams, and generic retryable API request failures — and every other 4xx is permanent. Authentication failures (401/403) surface as the structured `ApiError::Auth` from all three providers and cost exactly one streaming attempt with no backoff and no non-streaming fallback; 5xx/408/rate-limited errors keep the full ladder. Migration: code that relied on blanket 4xx retries (for example retrying a 404 in a poll loop) must handle the immediate typed error instead.
- **Behavior change: the `ApiClient` trait's default `stream_messages_with_options`/`create_message_with_options` implementations now reject every option field they cannot forward** — `response_format`, `tool_constraint`, and the per-request `model` override — with an `ApiError::config` error, instead of silently delegating to the options-less method. A silently dropped `model` override served a different model than the one the fallback breaker routed and reported for every non-overriding client. The three first-party providers override both methods and are unaffected; third-party `ApiClient` implementations that cannot forward an option now fail loudly, forcing the override.
- **Behavior change: a `ToolConstraint::Grammar` request to the Anthropic or Gemini client fails fast** with an `ApiError::config_validation` error, and a `response_format` with `strict: true` fails fast on both clients too (both APIs have no grammar-constrained or strict-forced tool decoding; previously the constraint was silently dropped and the request sent unconstrained/non-strict). Only OpenAI-compatible endpoints consume grammars (`guided_json`).
- **Fallback routing is real.** The `FallbackManager`'s documented contract — trip → subsequent requests served by a working fallback model, recovery probe → primary, failure on a fallback → chain advance — is now wired to the engine: the engine sources every turn's model from the breaker when one is configured (the `RequestOptions::model` seam), probes the primary at each turn boundary once the cooldown elapses, marks the active fallback failed when a failure happens on it, and fires `on_model_switched` on every model change regardless of cause. Breaker semantics landed with it: the failure counter resets on trip, a transient failure during the half-open probe resets the success streak (non-consecutive successes can no longer close the circuit), `active_model` returns `None` when a configured chain is fully exhausted, and an exhausted chain fails the turn with the new typed `LoopError::FallbackExhausted` (**breaking** — exhaustive matches add the arm) instead of silently serving the degraded primary. A client that rejects `set_model` now rejects the whole `ModelSwitch::apply` (proceeding re-pointed the manager at a model the client never adopted). **Breaking:** `FallbackManager::new_with_fallback` is removed — migrate to `FallbackManager::for_model(primary)` + `set_fallback_models(models)` (or `add_fallback_model`). Unconfigured managers change nothing: default loops send byte-identical requests.
- **Behavior change: `ToolContext::temp_dir` is now per-session when the context is built by `BareLoop`** — by default `{std::env::temp_dir()}/loopctl-{session_id}/`, materialised lazily on first tool dispatch and removed when the loop is dropped (best-effort: a missing dir is silent, another IO error logs one `warn!` — `Drop` cannot return errors). Previously the field was documented as per-session but actually held the process-wide OS temp, and nothing ever cleaned it — a slow disk leak for tools that spill results to temp. `BareLoop` now implements `Drop`; `into_machine()` still works (the machine is replaced out of the consumed husk, whose drop cleans the session temp — correct for checkpoint-and-resume). New builders: `with_temp_dir(base)` moves the subdir under a caller-supplied base (an empty path opts out); `with_managed_temp_disabled()` restores the old behavior entirely.
- **Behavior change: `TokenSplitter` no longer splits a tool call from its result.** A split point is accepted only when it is pair-safe — no tool result in the kept portion references a call in the dropped portion, however far apart the two messages sit — and when no pair-safe boundary exists at or before the target the whole conversation is preserved (`to_compact` empty) instead of falling back to an arbitrary index. Previously any assistant→user transition was a boundary, and host code discarding `to_compact` sent providers a stranded result.
- **`OutputLimitMiddleware` counts its truncation marker inside the budget** — a cut text part is exactly `max_chars` long (kept text + marker), and multipart parts that get zero remaining budget are emptied instead of each growing a fresh marker (a 50-part payload under a tiny cap previously emitted ~600 characters of markers, structurally exceeding the cap the middleware exists to enforce). `OutputLimitMiddleware::new(0)` now disables the middleware entirely (the crate-wide zero-disables sentinel) instead of reducing every output to a marker. Relatedly, `CircuitBreakerConfig::failure_threshold: 0` now disables tripping (failures counted for telemetry; the breaker never opens) — previously inverted (`0` tripped on the *first* failure).
- **`StreamTimeoutConfig::validate` rejects `Duration::MAX` for `total_stream_timeout`** — the value silently disables the total deadline, both backoff clamps, *and* the non-streaming fallback deadline via `checked_add` overflow. `with_timeout_config` substitutes only the invalid fields with defaults (infinite event timeouts substituted like zero ones; the ordering rule runs once after substitution), preserving the caller's valid customizations instead of discarding the whole config.
- **Provider HTTP clients set a read timeout (maximum gap between response bytes) instead of a total request timeout**, and `with_timeout` configures that read timeout. A total HTTP-layer cap aborted every SSE stream longer than the configured duration (default 2 minutes), pre-empting `StreamHandler`'s per-event/total-stream deadlines and the engine's turn timeout, which own generation-length budgets. Long healthy streams now run as long as they keep producing bytes; a server silent for the configured gap is still aborted.
- **Behavior change (engine detection defaults):** when the configured `ToolSignature` extracts an empty primary parameter (the default `NoOpToolSignature`'s blind spot), the engine records the operation under a canonical-JSON rendering of the tool input — two calls of one tool with *different* inputs but byte-identical outputs no longer count as repetition; a signature that does extract keeps full control of the key.
- **Behavior change (detection defaults):** `on_converge` now defaults to `ConvergenceAction::Warn` (was `Stop`), and `ConvergenceConfig::default` follows it (serde included) — one default across every construction family. Text similarity is a heuristic with irreducible false positives, so its default punishment is no longer execution; set `ConvergenceAction::Stop` to restore stop-on-converge. An `on_converge: AskUser` convergence now terminates the run with the new typed `LoopError::UserInputRequired` ask signal (**breaking** — exhaustive matches add the arm) instead of a mislabeled `LoopDetected` error; `Compact` and `SwitchPhase` remain host-executed (surfaced via `ConvergenceStatus::action`).
- **A memoize cache hit preserves the cached call's `duration`** instead of zeroing it, so `ToolStats` averages track the real tool latency; and engine health statistics are keyed on the executed (resolved) tool name when a routing middleware renamed it — the same key space `VerifyMiddleware` matches on.
- **`ConstrainedProfile` middleware registration order changed: output-limit (outermost) → verify → memoize** (was verify → memoize → output-limit). The cap now bounds everything below it — including verify's appended diagnostics, which previously escaped it — and memoize caches the uncapped, pre-verify result (a cache hit re-enters the outer cap and is re-truncated, so the larger cached value never bypasses the limit). Migration: hosts that copied the old order into their own pipelines should register the output limit first.
- **`AnthropicClientBuilder::build` rejects `max_tokens: 0`** (the Messages API requires ≥ 1 — previously a guaranteed server 400).
- The rate-limit retry ladder's docs are reconciled with the executed behavior: under the default config (`fallback_after_retries: 3 < max_retries: 5`) the ladder's only terminal outcome is `Escalate` (fail the turn; the engine records it against the circuit breaker, which routes subsequent turns to a fallback model) — a same-model non-streaming request is deliberately not attempted because rate limits are charged against the model's quota. `HardStop` (and its last-chance non-streaming fallback) is reachable only when `fallback_after_retries == max_retries`.
- **Breaking (poison policy):** mutexes protecting multi-field state machines now propagate `PoisonError` as the new `LoopError::LockPoisoned { what }` instead of silently recovering. Three subsystems: the `FallbackManager` breaker (every public method returns `Result`, `for_model` included — the old lossy `if let Ok(…)` idiom silently skipped mutations and mapped poison to `Primary`/`None` fail-open on a routing-critical read), the detection manager (`record_*`, `check_*`, `stats`, `reset` — the old `warn + into_inner` recovered possibly-desynchronised counters), and the token-bucket rate limiter (a new `#[non_exhaustive]` `RateLimitError { Wait(Duration) | Poisoned }` carrier on `take`/`take_at`/`acquire`; `available`/`available_at` propagate `LockPoisoned`). The engine handles each per its consequence: rate-limit poison is hard (pacing unavailable), detection poison is soft and sticky (detection is advisory — skipped for the rest of the session, one warn), fallback poison is hard (model selection cannot fail open). `LoopManagers::reset_all` returns `Result` likewise. `loopctl::error::recover_guard` (the recovery half, for single-operation data) is now `pub`, alongside the new `loopctl::error::from_poison` helper. Migration: callers of the changed methods add `?`/`match`; external `RateLimitError` matches need a wildcard arm.
- **Breaking:** the minimum supported Rust version is now **1.98** (was 1.94). CI is anchored to `1.98.0` instead of floating `stable`, so toolchain releases no longer change lint verdicts mid-PR. Migration: build with rustc ≥ 1.98; older toolchains get a clear `cargo` error from the `rust-version` gate.
- **Breaking:** `GitExecutor::auto_commit_with_files` takes two new parameters, `tool_name: Option<&str>` and `session_id: Option<&str>`, supplying the values behind the `{{tool}}`/`{{session}}` commit-message expansion. Migration: direct callers pass `None, None` when the values are unavailable; `AutoCommitHook` routes its tracked tool name and the run's session id automatically, and `GitExecutor::auto_commit` keeps its convenience shape.
- The three provider clients share one HTTP-error construction site: `post_json_checked` sends the request, reads `Retry-After` while the response is in hand, reads the diagnostic body bounded, and classifies the status into the structured variant. The `parse_retry_after` helper (delta-seconds + HTTP-date) moved to `api::error` so the provider clients and the stream handler's detection share one parser.

### Fixed

- **OpenAI streaming no longer silently discards tool arguments when the model writes text before a tool call** — the single most common streaming shape for small and local models. The text lane and the first tool call share part index 0; the accumulator's first-index-match routing let every argument fragment land in the text slot's buffer, which was dropped when that slot flushed as text, and the tool executed with `{}`. Fixed in three independent layers: kind-aware accumulator routing, addressed `PartStop`, and the OpenAI emitter closing its content lane before the first tool `PartStart`. The same collision between the thinking lane and a second tool call sharing a wire index is closed with it.
- **The fallback breaker actually routes** — see the fallback entry under Changed for the full contract; before this release the documented routing was never wired to the engine: on trip the next request went to the same failing primary until `max_turns`, the recovery probe never ran, and a dead fallback was never skipped.
- **Retry-After is captured.** Provider clients read the header at HTTP time and classify 429/503/529 responses into `ApiError::RateLimit { retry_after }`, so the server-advised-delay machinery (`RateLimitConfig::respect_retry_after`/`max_delay`) actually receives the provider's guidance — previously the header was discarded with the response object and downstream code tried to parse the delay back out of a flattened `"HTTP 429: {json}"` string, which can never carry it.
- **Mid-stream error events no longer masquerade as clean stops.** Anthropic's `event: error` (e.g. an `overloaded_error` after generation began) and Gemini's top-level error chunks are parsed into recorded terminal errors, and the stream terminates with them instead of a synthetic `MessageStop`. OpenAI mid-stream `{"error":{…}}` chunks — the shape OpenAI-compatible endpoints (vLLM, Ollama, gateways) emit on failure — are recognized (a bare-string `{"error": "…"}` payload too), rate-limit shapes classified into `ApiError::RateLimit`, and `finish` surfaces the recorded error as the stream's terminal failure. An HTTP 429 surfaced as `ApiError::Http` is classified `RateLimitKind::RateLimited` (503/529 as `Overloaded`) by `DetectedRateLimit::detect`, matching the variant docs.
- **A mid-stream total-stream timeout is no longer retried and re-reported as an init failure.** `decide_transport_error` treats a `TotalTimeout` outcome as terminal — the non-streaming fallback runs when configured, otherwise the turn fails once, never a second streaming attempt against the already-spent budget — and the already-built outcome propagates verbatim with its real `events_processed`/`has_partial_data`. The fallback races one fresh `initial_event_timeout` budget instead of the already-expired streaming deadline. `TotalTimeout::duration` spans every attempt in the turn. `StreamHandlerError::InitFailed` also renders as "stream failed before completing" in `Display` and the engine's `LoopError` mapping — a mid-stream truncation no longer surfaces as an initialization failure.
- **A malformed accumulator event (e.g. invalid tool-input JSON mid-stream) draws on the retry ladder** like every other mid-stream failure — retried within budget, handed the last-chance non-streaming request at exhaustion — instead of failing the turn on first occurrence.
- **Provider outbound wire correctness:** the OpenAI converter no longer silently drops the text parts of a message that also carries tool results (they ride along as a trailing `user` message after the `tool` messages, matching the Anthropic and Gemini converters); an empty tool slice serializes to *no* `tools` key in all three body builders (was `tools: []`, which Gemini answers with `400 INVALID_ARGUMENT`); the Anthropic `tool_result` block carries `"is_error": true` on failed tool dispatches; `with_base_url` trims trailing slashes in all three clients; the `zai` default model is `glm-4.7`.
- **Provider inbound wire correctness:** a streamed Anthropic turn reports real `input_tokens` — the emitter latches `message_start`'s `usage` (the only place Anthropic reports input counts on the streaming path) and the terminal `message_delta` merges the two reports field-wise by max, so cost accounting, context budgeting, and fallback tripping no longer see zero prompt tokens and the streamed and non-streamed paths agree; the Gemini non-streaming path no longer leaks reasoning summaries into the visible answer (`thought: true` parts excluded, matching the streaming accumulator); Gemini's streaming path latches `usageMetadata` from any chunk (proxies split it from the finish chunk); a streamed Gemini function call without `args` carries `input: {}` (explicit `"args": null` normalizes too; a non-object `args` passes through verbatim so malformed wire stays visible); SSE `data:` lines without the optional space — spec-legal, emitted by some third-party compatible servers — parse in all three providers, and spec-legal compact `event:` lines dispatch under their event type in the Anthropic reader.
- **Lenient JSON extraction completes on mixed nesting:** `extract_json_substring` tracks independent `{` and `[` depths instead of remembering only the outermost delimiter kind, so a fenced or prose-wrapped object containing an array (or vice versa) finally parses — previously the exact `LlmReflector` failure-analysis shape extracted as nothing and the structured-output rescue failed on every array-bearing payload. A closing delimiter arriving while its own depth is zero still abandons the candidate (garbage stays garbage). The lenient parser's tests run in the default build (the needless `openai`/`gemini` gates are gone). `tighten_json_schema` no longer silently loosens schemas either: `required` is now the union of pre-existing entries and property keys (it previously overwrote the list, dropping author-declared entries enforced structurally elsewhere).
- **The machine's context estimate is no longer zero at run start.** The driver feeds `set_context_tokens(count_context(full_history))` right after `accept_input`, so a session whose *committed* history exceeds the window (prior runs, a host-seeded resume, a checkpoint restore) compacts or fails typed before its first request instead of sending over-window. The estimate also refreshes the moment tool results are appended, so one turn's tool output — however large — is visible to the trigger before the next request. Note the accompanying policy change: a fresh input that itself crosses the threshold (or the 95% emergency line) triggers compaction before its first request; a run that compacts at start and then fails leaves its input committed (the same commit-point property mid-run compaction has always had).
- **No-op compaction passes no longer report a hard-coded zero estimate or commit the in-flight run's pending messages.** The driver's no-manager, pre-compact-hook-veto, and `NoAction` paths previously returned `tokens_after = 0` — blinding the machine's no-progress guard and resetting its context estimate, so the loop kept calling the provider over-window until the turn budget died — and worse, feeding the uncompacted history through `compaction_result` committed the current run's partial messages mid-run, leaking an aborted run's prompt and tool calls into committed history forever. These paths now return the measured estimate and leave pending untouched.
- **Compaction never carries an orphaned tool result forward, and never returns an empty list.** A result part whose call never appears before it (a duplicate result, a result with no preceding call) is dropped wherever it sits; messages emptied by the filtering are dropped; a conversation consisting solely of orphaned results is returned as received. An unanswered *call* in the recent slice is deliberately preserved (compaction can run between a response and its results). Call/result pairing is **per occurrence, not by id alone** — a call id reused across turns forms two distinct pairs (previously the id-presence checks conflated them, emitting a result without its own call). The split adjustment is re-applied to a fixed point (pulling the split back for one orphan can admit a message carrying a *different* orphan), and the first-message result reattachment no longer strands foreign results sharing the pulled message. A no-change pass that filtered orphaned results reports its savings with the caller's counter instead of claiming zero.
- **`TruncatingCompactor` no longer orphans a `ToolCall` carried by the unconditionally-preserved first message:** `adjust_for_tool_pairs` repaired only the result-in-recent/call-in-dropped direction, so a host-seeded `[A(tool_call), U(tool_result), …]` history compacted to a call without its result and Anthropic/OpenAI rejected the next request. The mirror direction now pulls the dropped result back adjacent to its call; a straddling call at index 0 returns the list unchanged (`NoAction`) instead of reporting success while reducing nothing.
- **`ContextOverflow::trigger` names the reason that actually triggered the failed pass** (both error sites hard-coded `Manual`, contradicting the field's own doc).
- **The "stuck" detectors no longer kill progressing runs.** The loop detector double-recorded every tool invocation (pre- and post-dispatch), so multipart results — which hashed to `None`, making both records identical — counted each call twice and killed a differing-content run at roughly half the configured stop threshold; `pre_detection` is now a pure window query and `post_detection` the single record point, hashing multipart results by rendered text. Convergence is fed only terminal responses (an acting turn's preamble is not a converged final answer), and an empty response resets the streak instead of silently preserving it. `DetectionStats::current_streak` is live below the loop threshold (previously frozen at zero during the build-up phase). `check_loop`/`check_current_pattern` no longer insert into the warned set, so a monitoring poller running alongside the loop can no longer consume the agent's one-shot loop warning.
- **Memoized tool results answer the requesting call, not the first one.** A memoize cache hit replayed the cached dispatch result verbatim, carrying the *first* call's `tool_call_id` — two results for one call and none for the other, which pairing-validating providers reject outright. The middleware's hit path stamps the requesting `ctx.call_id`, and the engine stamps ids authoritatively after the pipeline (a middleware-returned non-empty id is overridden), closing the class for any middleware that synthesizes or replays results. The memoize cache's insert is also epoch-guarded: a read whose dispatch straddled a concurrent write-class invalidation is no longer inserted afterwards.
- **Auto-commit message templates are expanded:** `{{tool}}` and `{{session}}` are replaced at the commit site with the triggering tool name and the session identifier (previously the template reached `git commit -m` verbatim). Most-recent-wins for `{{tool}}` across a run's tracked tools, inserted values are not re-scanned, an unsupplied value expands to an empty string, and unrelated braced text passes through verbatim.
- **`UnixShield` scoring gained token-boundary discipline and honest advisory scores.** Patterns match on word boundaries where the pattern's own edge is a word character; a symbol edge (`|`, `/`) cannot be extended into a longer token, so `/etc/` matches `/etc/passwd`, `.ssh/` matches `.ssh/authorized_keys`, and the pipes match the no-space `cmd|sh`, while `| sh` does not match `| sha256sum` and `curl` does not match `mycurlcmd`. An empty or whitespace-only pattern matches nothing (it previously scored every input). Command polymorphism cannot bypass the patterns: matching runs on a normalized view (lowercased, whitespace runs collapsed), and the split/long flag spellings of recursive delete (`rm -r -f`, `rm --recursive`) are patterns of their own — previously `rm  -rf  /` (double space), `RM -RF /`, and the flag spellings all sailed through. The bare `curl`/`wget` patterns dropped to 0.3 advisory (`curl --version` was previously blocked outright) while `curl … | sh` stays blocked; the compact pipe-to-shell spelling is a pattern of its own. Repetition amplifies risk but cannot block alone (the multi-turn dimension caps at 0.30 of the block threshold). `UnixShield`'s watched set now unions the combination-rule trigger tools, so a combination-only shield is actually consulted (its rules were previously dead).
- **Engine contracts:** re-polling `LoopMachine::next_step` in `AwaitingTools` returns the same `CallTools` step instead of an empty call list (an alternative driver that re-derives the step dispatches the real calls instead of silently feeding empty results and dangling `tool_use` parts); continuation turns carry real input — `turn_input` falls back to the last message's tool-result output text, so `Turn.input`, the `on_turn_start` query, and the `LoopMemory` retrieval key carry the dispatch context instead of `""` on every post-tool turn; the `cancel` doc describes the executed `tokio::select!` race (an in-flight tool invocation is dropped mid-flight when cancel wins — tools must be cancellation-safe); tool-result parts in a `CallTools` turn preserve model request order across preresolved and dispatched calls.
- **The non-streaming fallback forwards the turn's `RequestOptions` and is bounded by the total-stream deadline** (`StreamHandler::fallback_non_streaming`): it previously called `create_message` (dropping any configured `response_format`/`tool_constraint`) and raced only the cancel signal, so a hanging fallback request hung the turn.
- **Mock parity:** `MockApiClient::create_message` keeps the response text alongside a tool call (it built either the tool call or the text, never both, while its streaming twin emits both), reports the same usage as its streaming twin (50/25 instead of zeros), and streams tool-call arguments as `InputJson` deltas instead of embedding them in `PartStart` (the accumulator never read PartStart-carried input, so mock-served tool input silently arrived empty — every engine test mock ran tools with `{}`).
- **`ToolStats::record_failure` feeds the max-duration high-water mark**, so failed and timed-out calls count toward `max_duration()` — a tool whose every call times out no longer reports a zero longest call. Duration accumulation saturates at `u64::MAX` instead of wrapping.
- A refused dispatch (loop detection stopping a call before execution) emits the post-tool observer event alongside the pre-tool one, keeping the observer pairing balanced.
- **Census round-3 fixes:** a success recorded while the tool breaker is `Open` no longer closes it (such a success can only come from a call admitted before the trip; recovery is decided by a probe); `set_context_manager` syncs the session's `compact_threshold` alongside the window (a host manager no longer triggers at ~6× the intended point); `on_fallback`'s `from` names the model that was actually serving (the routed model), not the client's; memoize stores invalidation paths taken from the same input its cache key was built from, not an inner middleware's rewritten copy.

### Security

- Non-2xx diagnostic bodies are read **bounded** (an 8 KiB ceiling, enforced by a `Content-Length` pre-check plus a streaming stop for chunked responses), so a misbehaving endpoint answering a failed request with a multi-gigabyte page can no longer make the client materialize it before truncation.
- The new `redaction` feature (see Added) gives hosts a turnkey way to scrub secrets from tool output before it reaches the model or logs.
- Per-session temp directories with best-effort cleanup on `Drop` (see Changed) stop the slow disk leak of tools spilling results to the process-wide temp.

### Internal

- `engine/bare.rs` decomposed from 6,745 lines into a ~1,200-line facade plus focused submodules (`llm_turn` — both LLM-turn paths with a shared request builder, `config`, `emission` — every observer/hook fan-out centralized, `model_switch`, `tests`); each `MachineStep` arm maps to exactly one submodule. Internal cleanups landed with it: `MachineOutcome::to_loop_error` replaces three duplicated mapping sites; `RecoveryDecision` replaces a `Result` whose `Err` meant "not an error"; `TurnAccounting` bundles the turn start + token pair through dispatch; `dispatch_tool`/`dispatch_via_pipeline` return their result directly (they never returned `Err`); one `token_counter` source (`count_context` prefers the manager's counter); unified `millis_u64`. No public API impact beyond the breaking changes listed above.
- `make ci` now runs the default-feature test suite in addition to `--all-features`, closing a compile-only gap where integration files requiring the `streaming` engine path failed spuriously under every other feature combination, invisibly.
- Doc-truths sweep: dozens of doc claims across middleware, engine, providers, and detection were reconciled with executed behavior (memoize TTL boundaries, the truncation-marker length, cached-duration preservation, cap-on-cache-hit semantics, `with_timeout` describing the idleness bound it actually sets, per-request routing docs, mangled `with_base_url` doc splices, the warned-set band, hash cuts, `TimeoutMiddleware` retry counts, the `is_error` forwarding asymmetry at the non-forwarding converters, and more). The `run` `Done` arm matches every `MachineOutcome` variant explicitly (a future variant forces a compile error instead of being silently mislabelled `Cancelled`).

## [0.2.1] - 2026-08-04

### Fixed

- Detection-layer long-tail correctness (six items): `check_file_reads` now normalizes the query under the recorded op's real tool name (was: empty string) and matches by bidirectional path containment (was: exact equality), and rejects empty normalized params (an empty string is a substring of every query, which would inflate the read count); `LoopDetector::new` clamps `window_size == 0` to 1 with a warning; `find_best_match` rejects zero-score candidates even at threshold 0.0 and breaks ties lexicographically; `ToolShield::with_thresholds` swaps inverted warn/block pairs with a warning and rejects non-finite (NaN/inf) values, falling back to the band's default — a stored NaN would silently disable the band because every `score >= NaN` comparison is false. All non-breaking.
- `SessionConfig::compact_threshold` now clamps into its documented `0..=100` range on the validating construction paths (`Default`, `with_compact_threshold`, and `Deserialize`). Previously only the `with_compact_threshold` builder clamped; deserialized configs could carry an out-of-range value (e.g. `200` from disk), which the compaction subsystem would then interpret as "never compact." A single canonical clamp method plus a field-level serde deserialize helper now enforce the range silently. `Default` was already in range (80); its clamp call is defensive. Direct public struct-literal construction (`SessionConfig { compact_threshold: 200, .. }`) still bypasses normalization — the field is `pub`, and callers using that form are responsible for honoring the documented range. Non-breaking (silent clamp; no signature changes).
- `TokenBucket` refill clock no longer jumps to a future instant. The shared `elapsed_refill` helper advances `last_refill` only to the fill point (the instant capacity is reached) instead of to `at`, so a caller that passes a far-future instant — directly or via `take`/`acquire`/`available` — can no longer freeze the refill clock on the production rate-limit path. Non-breaking (callers passing correct `Instant::now()` values see no change).
- Corrected the `ParallelMode::Parallel` doc, which falsely claimed detection/observer side-effects "are not thread-safe" and fire "once on the final result only" in parallel mode. They are thread-safe (`DetectionManager` and the observer registry use `Mutex`/immutable-`Vec` interiors; `ToolHealthRegistry` uses atomics) and fire on every retry attempt in both modes, exactly as the code already does. No behavior change; the code matched the corrected doc all along.
- Fixed code-level doc contradictions: the `ApiClient` trait example showed `request: StreamRequest` (by-value) instead of `&StreamRequest` (matches the real trait), and `BareLoop::machine` was described as an "empty placeholder" rather than the real "empty machine (no history, no pending messages)".
- Reconciled the planning docs (ROADMAP, CONTEXT, ARCHITECTURE, README, DEPENDENCIES, DCH-DESIGN, the v0.2.0 release file) to the shipped 0.2.0 reality: status Planned→Shipped, `compact_threshold` u16→u8, `Loop::process_turn` soft-deprecated→removed, `LoopRuntime`/`LoopConfig`/`SessionResult`/`run_session` → their shipped replacements (`managers`/`SessionConfig`+`RunConfig`/`Run`+`Session`/`run`), MSRV 1.85→1.94, doctest count 303→286. Added a staleness banner to `LOOPCTL-DESIGN.md`.
- Restored the no-`#[allow(clippy::*)]` lint contract. Fixed: a private `TextStreamer` type alias, lossless integer-to-float casts (centralized in an internal `numeric` module), `PartLane`/`TerminalStage` lane enums replacing bool fields, and stale-allow deletions. No public API change.

## [0.2.0] - 2026-08-02

### Added

- `TurnMode` enum: engine runs non-streaming (`create_message`) or streaming (`StreamHandler`), selectable at runtime via `set_turn_mode`. Default is feature-dependent.
- `streaming` feature: gates `StreamHandler`, per-delta callbacks, `async-stream`. With `default = []`, no streaming code is compiled.
- Sans-IO `LoopMachine`: serializable, owns all loop decisions. Exposed via `machine()` / `into_machine()` / `from_machine()`.
- Session/Run/Turn lifetime model: `SessionConfig` (session-scoped) + `RunConfig` (per-run budgets).
- Reasoning-model support: `DeltaPart::Thinking` + `on_thinking_delta`.
- Structured output: `StructuredOutput` trait, `ResponseFormat`, `request_structured::<T>()`.
- Tool constraints (`ToolConstraint`): `Strict` and `Grammar` modes.
- Tool reflection (`LlmReflector`): model classifies failed tool calls and suggests corrections.
- Parallel tool dispatch (`ParallelDispatchConfig`).
- `StreamHandler`: retry, timeout, rate-limit backoff, non-streaming fallback.
- Client-side rate limiting: `TokenBucket` / `RateLimiter`, one bucket per `base_url`.
- `ContextContributor` trait: turn-boundary message injection.
- `LoopMemory` trait wired: stores trajectories, retrieves before turns, consolidates on success.
- `DisplayHint` on `ToolOutput`: rendering hints (Text, Diff, Json, Code, Suppress, Markdown).
- Middleware: `VerifyMiddleware`, `MemoizingMiddleware`.
- Presets: `ConstrainedProfile`, `FrontierProfile`, `GoalReminder`.
- `StreamRequest`: bundles `(messages, system, tools)` for all `ApiClient` methods.
- `Role::System` variant.
- Pluggable `TokenCounter` (`HeuristicTokenCounter` default).
- `OpenAiClientBuilder::with_stream_usage(bool)`.
- `with_tcp_nodelay(bool)` on all provider builders.
- HTTP connection-pool injection: shared `reqwest::Client`, pool knobs.
- Fluent `with_*()` builders on `BareLoop` and provider builders.
- `LoopError` derives `Serialize`, `Deserialize`, `PartialEq`, `Eq`.

### Changed

- **Breaking:** `default = []` no longer pulls `async-stream`; streaming is opt-in via `streaming`. HTTP providers imply it, so `features = ["openai"]` is unchanged. Migration: add `streaming` if you used `providers` alone.
- **Breaking:** `TurnMode` no longer implements `Default`. Migration: use `turn_mode()` / `set_turn_mode()`.
- **Breaking:** `create_message` returns typed `NonStreamingResponse` instead of `serde_json::Value`.
- **Breaking:** `extract_structured` takes `&Message`; per-provider overrides removed.
- **Breaking:** `run()` is `run(&mut self, &str, &RunConfig) -> Result<Run, LoopError>`. `Loop::initialize` / `config()` removed.
- **Breaking:** `on_session_start`/`on_session_end` renamed to `on_run_start`/`on_run_end`.
- **Breaking:** `LoopConfig` removed; split into `SessionConfig` + `RunConfig`.
- **Breaking:** compaction thresholds are `u8` percentages (0–100) instead of `f64`.
- **Breaking:** builders uniformly `with_`-prefixed; `Option<T>` builders take `Option<T>`.
- **Breaking:** `StreamHandler::stream_turn` returns `impl Stream<Item = Result<HandlerEvent, StreamHandlerError>>`.
- **Breaking:** `ApiClient` methods take `&StreamRequest` instead of positional params.
- **Breaking:** `DeltaPart`, `Role`, `ToolOutput`, `ToolDispatchResult`, `ToolPostContext` are `#[non_exhaustive]`.
- **Breaking:** `Reflector::analyze` gains `tool_schema: Option<&ToolSchema>`.
- **Breaking:** `MessagePart::ToolResult` gains `name` field (serde-defaulted for old data).
- **Breaking:** `NonStreamingResponse.usage` is `Option<Usage>`.
- Cancellation no longer trips the fallback breaker (`record_turn_failure` guards `Cancelled`).
- OpenAI streaming sets `stream_options.include_usage`; all providers report usage on both paths.
- OpenAI malformed `function.arguments` returns `ApiError` instead of defaulting to `{}`.
- Gemini parses `functionCall.id` (Gemini 3) and echoes it in responses.
- SSE invalid UTF-8 surfaced as protocol error instead of `U+FFFD` replacement.
- `StreamStopReason::from_api_str` accepts `"tool_use"` (Anthropic alias).
- MSRV bumped to 1.94.

### Removed

- `record_stream_success` / `record_stream_failure` (renamed to `record_turn_*`).
- `StreamCapable` trait now requires the `streaming` feature.
- `StreamHandler::with_config(timeout, retry)` — use `with_timeout_config` / `with_retry_config`.
- `FallbackManager::record_api_failure` / `record_model_failure` — merged into `record_failure(FailureKind)`.
- `Loop::process_turn`, `BareLoop::run_turn_body` — replaced by machine-driven `run()`.
- `StreamTurnResult`, `StreamHandler::with_request_options`, `StreamHandlerError::RateLimitEscalation.prior`.
- `parking_lot` dependency.

### Fixed

- Release profile no longer uses `panic = "abort"` (it disabled `catch_unwind` tool-panic isolation).
- OpenAI streaming dropped multi-chunk tool-call argument fragments.
- `StreamAccumulator` dropped parallel tool calls with interleaved arguments.
- `BareLoop` was dead after one cancellation (`CancelSignal` not re-armed; now resets in `finalize()`).
- Tool results split across multiple user messages instead of merged per turn.
- `StreamHandler` accepted invalid timeout/retry configs; `jitter_factor` was validated but never applied.
- Zero-event streams could hang ~20 min before failing.
- `ToolHealthRegistry::is_tool_available` consumed the HalfOpen probe as a read side effect.
- Anthropic provider hardcoded text-block index to 0.
- Per-run manager reset wiped session-scoped state.

### Security

- Auto-commit hook's `git add -A` on empty file list staged the whole working tree; now refuses.
- Response body size guard (10 MB) now pre-checks `Content-Length` instead of firing after full materialization.

## [0.1.0] - 2025-07-01

Initial crates.io release.

### Added

- Trait-based LLM loop framework with pluggable clients, tools, and memory
- Core message, error, and session types
- Convergence detection using Jaccard similarity
- Loop detector with configurable thresholds and hard-stop
- Fallback manager for model failover
- LLM streaming support
- Provider clients for OpenAI, Anthropic, Gemini, Ollama, DeepSeek, Grok, and ZAI
- Tool dispatch with panic isolation
- Tool health monitoring and tool shield middleware
- Interactive sessions with hooks
- Context compaction with truncating compactor
- Output limit middleware for token budget enforcement
- Built-in testing utilities for writing LLM loop tests
- Example CLIs: hello, REPL, echo tool, and multi-provider chat

[Unreleased]: https://github.com/dch-labs/loopctl/compare/v0.3.0...HEAD
[0.3.0]: https://github.com/dch-labs/loopctl/releases/tag/v0.3.0
[0.2.1]: https://github.com/dch-labs/loopctl/releases/tag/v0.2.1
[0.2.0]: https://github.com/dch-labs/loopctl/releases/tag/v0.2.0
[0.1.0]: https://github.com/dch-labs/loopctl/releases/tag/v0.1.0