Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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