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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
204 changes: 67 additions & 137 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,158 +7,87 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/2.0.0.

## [Unreleased]

## [0.2.0] - 2026-08-01
## [0.2.0] - 2026-08-02

### Added

- **Sans-IO state machine** (`engine::machine::LoopMachine`): serializable,
owns every agent-loop decision (turn counting, max-turn enforcement,
tool-call validity, compaction trigger, history, cancellation). Exposed via
`BareLoop::machine()` / `into_machine()` / `from_machine()` for inspection
and serialize-and-resume. `BareLoop::run()` now drives the machine internally.
- **Session/Run/Turn lifetime model** (`engine::core`): one `Session` spans the
process, one `Run` per `run()` call, one `Turn` per loop iteration. Session
derives per-session totals; `Run` carries per-run turns, tokens, and error.
Construction splits into `SessionConfig` (session-scoped) and `RunConfig`
(per-run budgets).
- **Reasoning-model support** (`DeltaPart::Thinking` + `on_thinking_delta`):
reasoning tokens (Claude extended-thinking, OpenAI o-series, Gemini 2.5+) are
routed as their own stream kind. Stream-only — not accumulated into `Message`.
- **Structured output** (`structured` module): `StructuredOutput` trait,
`ResponseFormat`, `request_structured::<T>()`. All three providers override
`stream_messages_with_options` / `create_message_with_options` to inject the
schema natively.
- **Tool constraints** (`ToolConstraint` enum): `Strict` tightens tool schemas
via the provider's native strict mode; `Grammar` compiles schemas into a
grammar for vLLM-style samplers (`grammar` feature).
- **Tool reflection** (`LlmReflector`): asks the model to classify failed tool
calls and suggest corrections via `request_structured`.
- **Parallel tool dispatch** (`ParallelDispatchConfig`): independent,
concurrency-safe calls within a single turn run concurrently. Sequential by
default.
- **Stream resilience** (`StreamHandler`): retries, timeouts, rate-limit
backoff, and non-streaming fallback. Configurable via
`with_timeout_config` / `with_retry_config` / `with_rate_limit_config`.
`HandlerEvent` enum provides real-time observability during streaming.
- **Client-side rate limiting** (`TokenBucket` / `RateLimiter`): proactive
per-provider token-bucket. One bucket per `base_url`.
- **Context contributors** (`ContextContributor` trait): turn-boundary hook for
injecting messages before each model call.
- **Agent memory** (`LoopMemory` trait, now wired): stores tool-call
trajectories, retrieves relevant entries before each turn, consolidates on
successful runs. Object-safe; configure via `BareLoop::set_memory`.
- **Display hints** (`DisplayHint` on `ToolOutput`): advisory rendering hints
(Text, Diff, Json, Code, Suppress, Markdown) for presentation layers.
- **Middleware**: `VerifyMiddleware` (post-execution verification),
`MemoizingMiddleware` (tool-call result caching with path-aware invalidation).
- **Presets** (`ConstrainedProfile`, `FrontierProfile`, `GoalReminder`): named
runtime profiles for small-model-tuned and frontier configurations.
- **`StreamRequest`**: bundles `(messages, system, tools)` into one parameter
for all `ApiClient` methods.
- **`Role::System`** variant: framework-injected system context. Providers map
to their native representation.
- **Pluggable `TokenCounter`**: `HeuristicTokenCounter` (4 chars/token) default;
swap in a real tokenizer. Synced bidirectionally with `ContextManager`.
- **`OpenAiClientBuilder::with_stream_usage(bool)`**: controls
`stream_options.include_usage`. `ollama()` disables it automatically.
- **`with_tcp_nodelay(bool)`** on all three provider builders + `HttpClientConfig`.
- **HTTP connection-pool injection**: shared `reqwest::Client`, pool knobs
(`pool_max_idle_per_host`, `pool_idle_timeout`, `tcp_keepalive`).
- **Fluent `with_*()` builders** on `LoopConfig`, `BareLoop`, and all provider
builders. `CancelSignal::reset()` for multi-run agents.
- `LoopError` now derives `Serialize`, `Deserialize`, `PartialEq`, `Eq`.
- `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 (`create_message` returns typed `NonStreamingResponse`):** no longer
returns raw `serde_json::Value`. The struct carries `message: Message`,
`stop_reason: StreamStopReason`, `usage: Option<Usage>`. Migration: read fields
from the struct.
- **Breaking (`extract_structured` takes `&Message`):** default implementation
derives from the message; per-provider overrides removed. Migration: change
parameter type or delete override.
- **Breaking (`run()` signature):** `run(&mut self, input: &str, &RunConfig)`
returns `Result<Run, LoopError>`. `Loop::initialize` / `config()` removed.
- **Breaking (session→run lifecycle rename):** `on_session_start` / `on_session_end`
→ `on_run_start` / `on_run_end`. Fire on every `run()` call. Migration: rename
methods and context types.
- **Breaking (`LoopConfig` removed):** replaced by `SessionConfig` + `RunConfig`.
The `model` field lives on `ApiClient`.
- **Breaking (compaction thresholds → percentages):** `f64` fractions → `u8`
percentages (0–100). `0.80 → 80`.
- **Breaking (builder renames):** all consuming builders uniformly `with_`-prefixed.
`Option<T>` builders take `Option<T>` (no `_opt` suffix). Migration: add prefix,
wrap literals in `Some(...)`.
- **Breaking (`StreamHandler::stream_turn`):** returns `impl Stream<Item =
Result<HandlerEvent, StreamHandlerError>>` instead of a future.
- **Breaking (`StreamRequest` parameter):** all `ApiClient` streaming/creation
methods take `&StreamRequest` instead of positional params.
- **Breaking (`DeltaPart` non-exhaustive):** add `_ =>` arm to downstream matches.
- **Breaking (`Role::System` variant):** add `System =>` arm to downstream matches.
- **Breaking (`ToolOutput` / `ToolDispatchResult` / `ToolPostContext`
non-exhaustive):** use named constructors or `..Default::default()`.
- **Breaking (`Reflector::analyze`):** gains `tool_schema: Option<&ToolSchema>`
parameter.
- **Breaking (`MessagePart::ToolResult` gains `name` field):**
`tool_result()` gains a `name` argument (second param). `#[serde(default)]`
allows deserializing older data.
- **Breaking (`NonStreamingResponse.usage` is `Option<Usage>`):** symmetric with
streaming. `None` = provider omitted usage.
- **OpenAI streaming usage:** now sets `stream_options.include_usage` and captures
token counts from the final chunk. All three providers report usage on both paths.
All-zero usage collapses to `None`.
- **OpenAI malformed arguments:** non-empty `function.arguments` that fail JSON
parse now return an `ApiError` instead of silently defaulting to `{}`.
- **Gemini tool-call ids:** `functionCall.id` is now parsed (Gemini 3) and echoed
in `functionResponse.id`. `functionResponse` sends both `name` and `id`.
- **SSE invalid-UTF-8:** `take_line` surfaces invalid UTF-8 as a protocol error
instead of silent `U+FFFD` replacement.
- **`StreamStopReason::from_api_str`** accepts `"tool_use"` as alias for
`"tool_call"` (Anthropic).
- **`set_token_counter`** now propagates to the `ContextManager` if one is set,
regardless of setter order.
- **Memory injection** uses `Role::User` (not `Role::System`) with explicit
"reference only" delimitation.
- **Context token estimate** includes the model response message before counting.
- MSRV bumped to 1.94 (let-chain syntax). Removed `parking_lot` dependency.
- **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

- `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` — engine assembles result from event stream.
- `StreamHandler::with_request_options` — use `BareLoop::set_request_options`.
- `StreamHandlerError::RateLimitEscalation.prior` field (never read).
- `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

- OpenAI streaming dropped multi-chunk tool-call argument fragments after the
first; re-opened tool-call parts on every chunk carrying `function`.
- `StreamAccumulator` dropped parallel tool calls whose arguments arrived
interleaved.
- `BareLoop` was permanently dead after a single cancellation (`CancelSignal`
not re-armed). Now resets in `finalize()`.
- Tool results were split across multiple user messages instead of merged into
one per turn.
- `StreamHandler` accepted invalid timeout/retry configs (validation never
called); `jitter_factor` was validated but never applied.
- Empty-stream fast-fail: zero-event streams could hang for ~20 min before
failing.
- `ToolHealthRegistry::is_tool_available` consumed the HalfOpen recovery probe
as a side effect of a read.
- Anthropic provider hardcoded text-block index to 0, ignoring server index.
- Per-run manager reset wiped session-scoped state between runs.
- 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 entire working
tree. Now refuses with an error.
- Non-streaming response body size guard (10 MB) fired after full
materialization. Now pre-checks `Content-Length` and caps streaming reads.
- 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

Expand All @@ -181,4 +110,5 @@ Initial crates.io release.
- Built-in testing utilities for writing LLM loop tests
- Example CLIs: hello, REPL, echo tool, and multi-provider chat

[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
19 changes: 14 additions & 5 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "loopctl"
version = "0.1.0"
version = "0.2.0"
edition = "2024"
license = "MIT OR Apache-2.0"
description = "A trait-based framework for building agent loops with pluggable LLM clients, tools, and memory"
Expand Down Expand Up @@ -30,7 +30,7 @@ tracing = "0.1"

reqwest = { version = "0.13", default-features = false, features = ["json", "stream", "rustls"], optional = true }
bytes = { version = "1", optional = true }
async-stream = "0.3"
async-stream = { version = "0.3", optional = true }
httpdate = { version = "1", optional = true }
jsonschema = { version = "0.49", optional = true }

Expand All @@ -45,15 +45,18 @@ testing = []
tool_health = []
tool_shield = ["tool_health"]

# Streaming engine path (StreamHandler, per-delta callbacks, async-stream codegen).
streaming = ["dep:async-stream"]

# Providers
providers = ["dep:reqwest", "dep:httpdate", "dep:bytes"]
openai = ["providers"]
anthropic = ["providers"]
openai = ["providers", "streaming"]
anthropic = ["providers", "streaming"]
ollama = ["providers", "openai"]
deepseek = ["providers", "openai"]
grok = ["providers", "openai"]
xai = ["grok"]
gemini = ["providers"]
gemini = ["providers", "streaming"]
zai = ["providers", "anthropic"]
grammar = ["providers"]
schema_validation = ["dep:jsonschema"]
Expand Down Expand Up @@ -92,3 +95,9 @@ let_underscore_must_use = "deny"
[package.metadata.docs.rs]
all-features = true
rustdoc-args = ["--cfg", "docsrs"]

[profile.release]
lto = "fat"
codegen-units = 1
strip = true

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Loading