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
2 changes: 1 addition & 1 deletion .clippy.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ too-many-lines-threshold = 150
too-many-arguments-threshold = 7

# MSRV from Cargo.toml.
msrv = "1.94.0"
msrv = "1.98.0"

# Warn when passing types larger than this by value (bytes).
trivial-copy-size-limit = 32
Expand Down
16 changes: 8 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.98.0
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- uses: Swatinem/rust-cache@v2
- run: cargo check --all-features

Expand All @@ -25,7 +25,7 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.98.0
- uses: Swatinem/rust-cache@v2
- run: cargo test --all-features

Expand All @@ -35,7 +35,7 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.98.0
with:
components: clippy
- uses: Swatinem/rust-cache@v2
Expand All @@ -47,7 +47,7 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.98.0
with:
components: clippy
- uses: Swatinem/rust-cache@v2
Expand All @@ -59,7 +59,7 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.98.0
with:
components: rustfmt
- run: cargo fmt --all -- --check
Expand All @@ -70,7 +70,7 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.98.0
- uses: Swatinem/rust-cache@v2
- run: cargo test --doc --all-features

Expand All @@ -80,7 +80,7 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.98.0
- uses: Swatinem/rust-cache@v2
- run: cargo doc --no-deps --all-features
env:
Expand All @@ -92,6 +92,6 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.98.0
- uses: Swatinem/rust-cache@v2
- run: cargo build --examples --all-features
2 changes: 1 addition & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ jobs:
exit 1
fi

- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.98.0

- uses: Swatinem/rust-cache@v2

Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/2.0.0.

### Added

- `LoopMachine::set_context_tokens(tokens)` — a driver-fed context estimate. The driver measures the conversation (`count_context`, preferring the manager's counter) whenever history grows outside a model response — after `accept_input` at run start, so the machine's compaction trigger sees the true size of committed history plus the new input before the run's first model call instead of the zero `accept_input` resets to. Estimate-only: no state transition, no effect once terminal.
- `LoopMachine::compaction_noop(tokens_before, tokens_after)` — a second compaction feed method for passes that changed nothing (no compactor ran, a pre-compact hook vetoed, or the compactor returned the conversation unchanged). Unlike `compaction_result`, it leaves the committed history and the pending buffer untouched and adopts `tokens_after` as the current context size. Both compaction feeds compare the driver's measured pre-pass and post-pass token counts: when nothing was shaved off, the machine terminates the run with `ContextExceeded` instead of trusting an estimate recorded at compaction-request time (which could be stale — set before the last turn's tool results, or zero at run start). Alt drivers servicing `MachineStep::Compact` should pass both measurements from one counter.
- `McpToolProvider::with_call_timeout(Duration)` — a per-call budget for every adapted tool's `tools/call` round-trip (default 60s). A call that exceeds it 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 agent loop indefinitely. Updates already-discovered tools and applies to later refreshes. Pinned by `call_timeout_cuts_a_slow_tool_with_a_soft_error` / `default_timeout_lets_a_quick_tool_through` in `tests/mcp_tool_provider.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`). Served calls are raced against the request's cancellation token: an already-cancelled request resolves to a cancelled result without invoking the tool, and a client cancel (`notifications/cancelled`) or disconnect drops the in-flight tool future and resolves to a cancelled tool-level result — a wedged tool no longer leaks a task per call (tools must be cancellation-safe, the same contract the engine's dispatch path imposes). `list_tools` forwards each tool's `is_read_only` as the MCP `annotations.readOnlyHint` (with `destructiveHint: false`); 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 from the listing with a warning rather than advertised with a schema strict clients may reject (the `mcp` feature now pulls `jsonschema` for this check); unknown tool names return a `METHOD_NOT_FOUND` protocol error listing the registered names; empty descriptions are omitted rather than sent as `""`. Tool names are forwarded verbatim — the MCP spec recommends `^[a-zA-Z0-9_-]{1,64}$` and conforming names are the embedding application's responsibility (documented in the module docs). Transport-agnostic: `serve(impl IntoTransport)` works for future HTTP/SSE. The module promoted to `mcp/{convert,server}.rs` with symmetric inbound/outbound converters co-located. Example at `examples/mcp_server.rs` (echo + failing tool, Ctrl-C → graceful cancel; includes a piped JSON-RPC acceptance recipe).
- MCP transports (stdio + Streamable HTTP/SSE) — `McpClient::stdio(command)` spawns an MCP server as a child process over stdio; `McpClient::http_sse(endpoint)` and `McpClient::http_sse_with_client(endpoint, reqwest::Client)` connect via the Streamable HTTP transport (rmcp handles `Mcp-Session-Id`, JSON-vs-SSE response splitting, and DELETE-on-close). `McpClient::reconnect(&StreamRetryConfig)` re-establishes a dropped connection using the crate's existing backoff strategy (the one retry strategy for the crate, not a second one). New public `CommandSpec` describes what to spawn. A stdio example server ships at `examples/mcp-stdio-server.rs` for transport testing. The `mcp` feature now enables `streaming` (for `StreamRetryConfig`) and `reqwest` (for the HTTP client); `default = []` is unchanged.
Expand All @@ -20,6 +22,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/2.0.0.

### Changed

- **Breaking:** the minimum supported Rust version is now **1.98** (was 1.94), riding the 0.3.0 minor bump. 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 (`rustup update stable`); older toolchains get a clear `cargo` error from the `rust-version` gate.
- `BareLoop` constructors now 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` (only when the bundle has no context manager), and `from_machine`. Behavior change: 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 and sending over-window conversations. `auto_compact: false` still disables threshold compaction, and hosts installing their own `ContextManager` are unaffected. Pinned by `tests/compaction_noop.rs` (`over_limit_context_is_never_sent_to_the_provider`, `default_loop_compacts_at_the_threshold`, `host_installed_context_manager_is_unaffected`).
- `ConstrainedProfile::apply` attaches a `ContextManager` synced from the loop's session config (replacing whatever the constructor seeded), so the profile's context budgeting is enforced machinery rather than a marketing claim. Pinned by `small_model_profile_compacts_at_the_threshold` in `tests/compaction_noop.rs`.
- `ContextManager::compact_with_reason` (and `compact_manual` / `ensure_context_fits`) now report a successful pass that shrinks neither the message list nor the token count as `EnsureContextResult::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` on the `Compacted` outcome are normalized to the manager's measurements, and `compact_with_reason`'s overflow check re-counts the result with the same counter (previously it trusted the compactor-reported value). Callers matching `Compacted` to learn "compaction occurred" no longer see no-action passes; `on_compaction` observers and post-compact hooks stay silent for them (the engine already skips both on `NoAction`). Migration: code that treated any `Ok(Compacted(..))` as "the messages may have changed" should use `into_messages()` — the returned list is identical under `NoAction`.
- **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 the compacted size after it, replacing the estimate the machine used to record at compaction-request time. The machine's `last_compaction_tokens` field is gone (its serialized state changes accordingly for checkpoints). Migration: pass the two measurements from the same counter the driver uses for its context estimate; the no-progress guard fails the run when `tokens_after >= tokens_before`.
- Provider HTTP clients now 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 the `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 (default 120s) is still aborted. Behavior change: generations longer than the old total cap no longer fail at the HTTP layer.

- **Breaking:** Machine turn indices are now 0-indexed (`CallLLM { turn: 0 }` for the first turn). Previously 1-indexed (`turn: 1`). `AwaitingModel { turn }` and `AwaitingTools { turn }` follow the same convention. Callers matching on these variants in tests or drivers must adjust.
Expand All @@ -42,6 +49,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/2.0.0.

### Fixed

- 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 the compaction trigger runs before a run's first model call. Previously a session whose *committed* history exceeded the context window (from prior runs, a host-seeded resume, or a `from_machine` checkpoint restore) sent its first request over-window before any check could run (pinned by `first_request_of_an_over_window_run_is_never_sent`, previously failing at `[192, 217]` against a 200-token window); such a run now compacts first or fails with a typed `LoopError::ContextExceeded` before any request. The same measurement now also runs after tool dispatch: the estimate is refreshed the moment tool results are appended (`set_context_tokens` after `tool_results`), so one turn's tool output — however large — is visible to the compaction trigger before the next request (pinned by `tool_result_growth_alone_crosses_the_threshold`). Landing that exposed a mock divergence: `MockApiClient` now streams tool-call arguments as an `InputJson` delta instead of embedding them in `PartStart`, matching how real providers stream — the accumulator never read PartStart-carried input, so mock-served tool input silently arrived empty. Note the accompanying policy change: a fresh input that itself crosses the threshold (or the 95% emergency line) now triggers compaction before its first request, where previously the check was blind until the first response.
- No-op compaction passes no longer report a hard-coded zero estimate or commit the in-flight run's pending messages. Previously the driver's no-manager, pre-compact-hook-veto, and `NoAction` paths returned `tokens_after = 0`, which blinded the machine's no-progress guard and reset its context estimate — the loop kept calling the provider with an over-window conversation until the turn budget was spent. Worse, feeding the uncompacted history back through `compaction_result` committed the current run's partial messages mid-run, so a later failure leaked the aborted run's prompt, tool calls, and results into committed history forever (`discard_pending` could no longer undo it). These paths now return the measured estimate (`count_context`) through an explicit no-op signal that leaves pending untouched; when compaction genuinely cannot reduce, the run terminates with a typed `LoopError::ContextExceeded`. Pinned by `failed_run_after_noop_compaction_leaves_history_clean` (engine) and `pre_compact_hook_veto_reports_measured_estimate` (hooks) — the hook-veto pass now surfaces the real size instead of zero.
- A no-change compaction pass is no longer reported as `EnsureContextResult::Compacted`: `ContextManager` used to wrap any successful compactor outcome as `Compacted`, so short-but-over-threshold conversations fired `on_compaction` observers (and post-compact hooks) on every triggering turn with zero savings and identical messages, violating both contracts. Same-list/zero-savings outcomes now map to `NoAction`. Pinned by `no_change_pass_is_not_reported_as_compacted` (compact.rs).
- 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. Pinned by `fallback_non_streaming_forwards_request_options`, `fallback_non_streaming_honors_the_total_deadline`, and `completed_fallback_response_racing_the_deadline_is_accepted` (the deadline bounds waiting, not completion — a resolved response is accepted even past expiry, while a hanging one is still cut).
- MCP `tools/call` round-trips are bounded by the new per-call timeout (see `McpToolProvider::with_call_timeout` above) — previously a wedged server hung the agent loop with cancellation as the only exit.
- Tool-result parts in a `CallTools` turn preserve **model request order** across preresolved (unknown-tool) and dispatched calls. Previously the turn's results were assembled as `[all preresolved, then all dispatched]`, which reordered the parts the model saw relative to the calls it made. Provider-safe in practice (providers match by `tool_call_id`, not position), but order-non-preserving and surprising to hosts that assume positional alignment. Pinned by `test_mixed_known_unknown_tools_preserve_request_order`.
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ documentation = "https://docs.rs/loopctl"
keywords = ["agent", "framework", "llm", "loop"]
categories = ["api-bindings", "development-tools"]
readme = "README.md"
rust-version = "1.94"
rust-version = "1.98"

[lib]
name = "loopctl"
Expand Down
2 changes: 1 addition & 1 deletion examples/mcp-adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ impl GreetServer {
"hello, world!".to_string()
}
}

#[allow(clippy::unused_async_trait_impl)] // FIXME(rmcp): drop when tool_handler emits awaits
#[tool_handler]
impl ServerHandler for GreetServer {}

Expand Down
2 changes: 1 addition & 1 deletion examples/mcp-stdio-server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ impl StdioServer {
"hello from stdio".to_string()
}
}

#[allow(clippy::unused_async_trait_impl)] // FIXME(rmcp): drop when tool_handler emits awaits
#[tool_handler]
impl ServerHandler for StdioServer {}

Expand Down
Loading