From 334dfc6e1d9d1cb054c803b68f02c424f4fffe06 Mon Sep 17 00:00:00 2001 From: Iurii Bobrykov Date: Wed, 29 Jul 2026 19:50:11 +1200 Subject: [PATCH 01/20] fix: use StreamStopReason to detect stop reason --- src/engine/bare.rs | 59 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 54 insertions(+), 5 deletions(-) diff --git a/src/engine/bare.rs b/src/engine/bare.rs index e22f82c..5f341c7 100644 --- a/src/engine/bare.rs +++ b/src/engine/bare.rs @@ -1511,7 +1511,7 @@ impl BareLoop { Err(LoopError::Cancelled) } stream_outcome = self.do_stream(contributor_messages) => { - let (msg, usage, _stream_stop) = match stream_outcome { + let (msg, usage, stream_stop) = match stream_outcome { Ok(triple) => triple, Err(LoopError::Cancelled) => { self.notify_turn_end( @@ -1545,10 +1545,17 @@ impl BareLoop { input: input.clone(), }) .collect(); - let stop_reason = if tool_calls.is_empty() { - StopReason::EndTurn - } else { - StopReason::ToolCall + let stop_reason = match stream_stop { + StreamStopReason::ToolCall => StopReason::ToolCall, + StreamStopReason::MaxTokens => StopReason::MaxTokens, + StreamStopReason::StopSequence => StopReason::StopSequence, + StreamStopReason::EndTurn => { + if tool_calls.is_empty() { + StopReason::EndTurn + } else { + StopReason::ToolCall + } + } }; let model_response = ModelResponse { message: msg, @@ -2027,6 +2034,37 @@ mod tests { crate::error::recover_guard(self.responses.lock()).push(tool_events); } + fn add_max_tokens_response(&self, text: &str) { + let events = vec![ + StreamEvent::MessageStart(MessageStart { + message: MessageMetadata { + id: "msg_mt".into(), + role: "assistant".into(), + model: crate::error::recover_guard(self.model_name.lock()).clone(), + }, + }), + StreamEvent::PartStart(PartStart { + index: 0, + part: Some(MessagePart::text(text)), + }), + StreamEvent::IndexedDelta(IndexedDelta { + index: 0, + delta: DeltaPart::Text { + text: text.to_string(), + }, + }), + StreamEvent::PartStop, + StreamEvent::MessageDelta(MessageDelta { + delta: MessageDeltaPayload { + stop_reason: Some("max_tokens".to_string()), + }, + usage: Some(Usage::new(10, 20)), + }), + StreamEvent::MessageStop, + ]; + crate::error::recover_guard(self.responses.lock()).push(events); + } + #[expect(dead_code)] fn add_error_response(&self) { // Return an empty response that will cause the stream to error @@ -2727,6 +2765,17 @@ mod tests { assert_ne!(first_run, second_run, "id rotates per run"); } + #[tokio::test] + async fn max_tokens_stop_reason_preserved() { + let client = MockClient::new("test-model"); + client.add_max_tokens_response("truncated"); + + let mut agent = BareLoop::new(Arc::new(client), ToolRegistry::new(), make_config()); + let result = agent.run("generate", &RunConfig::default()).await.unwrap(); + + assert_eq!(result.turn_count(), 1); + } + #[tokio::test] async fn test_bare_loop_max_turns_exceeded() { let client = MockClient::new("test-model"); From fc49b7fa6615ab6ab63a7fad677ad8afa44baa98 Mon Sep 17 00:00:00 2001 From: Iurii Bobrykov Date: Wed, 29 Jul 2026 21:44:53 +1200 Subject: [PATCH 02/20] fix: stop reason on boundary completion --- src/engine/bare.rs | 145 ++++++++++++++++++++++++++++++++++-- src/engine/bare/emission.rs | 38 +++++----- 2 files changed, 158 insertions(+), 25 deletions(-) diff --git a/src/engine/bare.rs b/src/engine/bare.rs index 5f341c7..5cf0114 100644 --- a/src/engine/bare.rs +++ b/src/engine/bare.rs @@ -1818,12 +1818,16 @@ impl crate::engine::core::Loop for BareLoop { if self.is_cancelled() { return Some(LoopError::Cancelled); } - let max_turns = self.run_config().max_turns; - let turns = self.current_run().map_or(0, Run::turn_count); - if turns >= max_turns { - return Some(LoopError::MaxTurnsExceeded { max: max_turns }); + match self.machine.state() { + MachineState::Terminal(MachineOutcome::Failed { error }) => Some(error), + MachineState::Terminal(MachineOutcome::MaxTurnsExceeded) => { + Some(LoopError::MaxTurnsExceeded { + max: self.run_config().max_turns, + }) + } + MachineState::Terminal(MachineOutcome::Cancelled) => Some(LoopError::Cancelled), + _ => None, } - None } } @@ -4645,11 +4649,32 @@ mod tests { assert_eq!(hook.captured(), Some(RunEndReason::Cancelled)); } + /// A genuine max-turns run exits via the machine's + /// `MaxTurnsExceeded` arm, which carries the typed error through + /// finalize — not a turn-count heuristic. #[cfg(feature = "hooks")] #[tokio::test] async fn run_end_reason_max_turns() { + let (loop_, hook) = loop_with_reason_hook(); + let err = LoopError::MaxTurnsExceeded { max: 5 }; + + loop_.notify_run_end( + &loop_.current_run().unwrap().clone(), + Duration::from_millis(100), + Some(&err), + ); + + assert_eq!(hook.captured(), Some(RunEndReason::MaxTurns)); + } + + /// A run that legitimately completes on exactly the `max_turns`-th + /// turn reaches finalize with `error = None`. The turn count is a + /// red herring: the machine emitted `Completed`, not + /// `MaxTurnsExceeded`, so the reason must be `Complete`. + #[cfg(feature = "hooks")] + #[tokio::test] + async fn run_end_reason_complete_on_max_turn_boundary() { let (mut loop_, hook) = loop_with_reason_hook(); - // Hit max_turns: turn count == max_turns, not cancelled, success true. loop_.current_run_mut().unwrap().turns = (0..5) .map(|i| crate::engine::core::Turn { turn: i, @@ -4667,7 +4692,7 @@ mod tests { None, ); - assert_eq!(hook.captured(), Some(RunEndReason::MaxTurns)); + assert_eq!(hook.captured(), Some(RunEndReason::Complete)); } #[cfg(feature = "hooks")] @@ -4703,6 +4728,112 @@ mod tests { assert_eq!(hook.captured(), Some(RunEndReason::ContextOverflow)); } + #[test] + fn stop_reason_is_none_before_terminal() { + use crate::engine::core::Loop; + let loop_ = BareLoop::new( + Arc::new(MockClient::new("test")), + ToolRegistry::new(), + SessionConfig::default(), + ); + assert_eq!(loop_.stop_reason(), None); + } + + #[test] + fn stop_reason_reports_terminal_outcome() { + use crate::engine::core::Loop; + let mut loop_ = BareLoop::new( + Arc::new(MockClient::new("test")), + ToolRegistry::new(), + SessionConfig::default(), + ); + loop_.machine.fail(LoopError::Api("boom".into())); + assert_eq!(loop_.stop_reason(), Some(LoopError::Api("boom".into()))); + + let mut loop_ = BareLoop::new( + Arc::new(MockClient::new("test")), + ToolRegistry::new(), + SessionConfig::default(), + ); + loop_.machine.cancel(); + let policy = loop_.machine_policy(); + let _ = loop_.machine.next_step(policy); + assert_eq!(loop_.stop_reason(), Some(LoopError::Cancelled)); + + // Drive the machine to a genuine MaxTurnsExceeded terminal state + // by exhausting a budget of one: request the model, respond with + // a tool call, then request again — the third next_step hits the + // cap. stop_reason must surface the typed error. The machine is + // policy-free, so the budget is passed directly to next_step. + let mut loop_ = BareLoop::new( + Arc::new(MockClient::new("test")), + ToolRegistry::new(), + SessionConfig::default(), + ); + loop_.session.runs.push(Run::new( + "", + &RunConfig { + max_turns: 1, + ..RunConfig::default() + }, + )); + let policy = loop_.machine_policy(); + let _ = loop_.machine.next_step(policy); + let part = MessagePart::tool_call("c1", "echo", serde_json::Value::Null); + let response = ModelResponse { + message: Message::new(Role::Assistant, vec![part]), + input_tokens: 0, + output_tokens: 0, + stop_reason: StopReason::ToolCall, + available_tools: vec!["echo".to_string()], + }; + loop_.machine.model_response(response); + let _ = loop_.machine.next_step(policy); + loop_.machine.tool_results(vec![Message::user("r")]); + let step = loop_.machine.next_step(policy); + assert!(matches!( + step, + MachineStep::Done(MachineOutcome::MaxTurnsExceeded) + )); + assert_eq!( + loop_.stop_reason(), + Some(LoopError::MaxTurnsExceeded { max: 1 }) + ); + } + + #[test] + fn stop_reason_completion_on_max_turn_boundary_is_none() { + use crate::engine::core::Loop; + let mut loop_ = BareLoop::new( + Arc::new(MockClient::new("test")), + ToolRegistry::new(), + SessionConfig::default(), + ); + // A run that legitimately completes on exactly the max_turns-th + // turn ends with the machine in the Completed terminal state, not + // MaxTurnsExceeded. stop_reason must reflect that: None, not + // MaxTurnsExceeded. This is the regression the old turn-count + // heuristic got wrong. + let final_msg = Message::assistant("done"); + let response = ModelResponse { + message: final_msg, + input_tokens: 0, + output_tokens: 0, + stop_reason: StopReason::EndTurn, + available_tools: Vec::new(), + }; + let policy = MachinePolicy { + max_turns: 1, + context_window: 200_000, + compact_threshold: 80, + auto_compact: true, + }; + let _ = loop_.machine.next_step(policy); + loop_.machine.model_response(response); + assert!(loop_.machine.is_terminal()); + assert_eq!(loop_.stop_reason(), None); + } + #[tokio::test] async fn run_cancel_during_streaming_returns_fast() { let (client, tx) = StreamingMockClient::new("test-model"); diff --git a/src/engine/bare/emission.rs b/src/engine/bare/emission.rs index 3ff8a7c..19fc743 100644 --- a/src/engine/bare/emission.rs +++ b/src/engine/bare/emission.rs @@ -53,27 +53,29 @@ impl BareLoop { /// Derive the structured [`RunEndReason`] from the terminal error. /// - /// Cancellation takes precedence over every other outcome. Then: - /// [`LoopError::ContextExceeded`] maps to - /// [`ContextOverflow`](RunEndReason::ContextOverflow), any other - /// error maps to [`Error`](RunEndReason::Error). When `error` is - /// `None`, reaching `max_turns` maps to - /// [`MaxTurns`](RunEndReason::MaxTurns), otherwise the run - /// completed normally ([`Complete`](RunEndReason::Complete)). + /// Maps the authoritative terminal [`LoopError`] carried out of + /// [`run`](crate::engine::core::Loop::run) — never the turn count, + /// since a run that legitimately completes on exactly the + /// `max_turns`-th turn reaches `error = None` and must read as + /// [`Complete`](RunEndReason::Complete), not `MaxTurns`. Cancellation + /// (signalled or carried by [`LoopError::Cancelled`]) takes + /// precedence; then [`LoopError::ContextExceeded`] maps to + /// [`ContextOverflow`](RunEndReason::ContextOverflow), + /// [`LoopError::MaxTurnsExceeded`] to + /// [`MaxTurns`](RunEndReason::MaxTurns), any other error to + /// [`Error`](RunEndReason::Error), and `None` to + /// [`Complete`](RunEndReason::Complete). #[cfg(feature = "hooks")] fn run_end_reason(&self, error: Option<&LoopError>) -> RunEndReason { if self.is_cancelled() { - RunEndReason::Cancelled - } else if let Some(e) = error { - if matches!(e, LoopError::ContextExceeded { .. }) { - RunEndReason::ContextOverflow - } else { - RunEndReason::Error - } - } else if self.current_run().map_or(0, Run::turn_count) >= self.run_config().max_turns { - RunEndReason::MaxTurns - } else { - RunEndReason::Complete + return RunEndReason::Cancelled; + } + match error { + Some(LoopError::ContextExceeded { .. }) => RunEndReason::ContextOverflow, + Some(LoopError::MaxTurnsExceeded { .. }) => RunEndReason::MaxTurns, + Some(LoopError::Cancelled) => RunEndReason::Cancelled, + Some(_) => RunEndReason::Error, + None => RunEndReason::Complete, } } From 44a86cae602c0a096d427badbe9816d1cc6ebadb Mon Sep 17 00:00:00 2001 From: Iurii Bobrykov Date: Thu, 30 Jul 2026 13:12:32 +1200 Subject: [PATCH 03/20] chore: shared deadline future, split config builders, add jitter delay --- CHANGELOG.md | 35 +++ src/engine/bare.rs | 18 +- src/stream/handler.rs | 564 +++++++++++++++++++++++++++--------------- 3 files changed, 399 insertions(+), 218 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf09438..cd0b0cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -211,6 +211,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/2.0.0. `Arc` observe the new token, so a handle returned by `BareLoop::cancel_signal()` keeps working across resets. `BareLoop` calls this in `finalize()` so each `run()` starts with a clean signal. +- `StreamHandler::with_timeout_config(config)` and + `with_retry_config(config)` — independent, self-validating builders for + the streaming timeout and retry configs (mirroring the existing + `with_rate_limit_config`). Each validates its config and falls back to + the default on an invalid value, replacing the coupled + `with_config(timeout, retry)` builder. +- `StreamRetryConfig::jittered_base_delay(attempt)` — the exponential + backoff with [`jitter_factor`](crate::stream::handler::StreamRetryConfig::jitter_factor) + applied, used by `StreamHandler` between transport-retry attempts. The + jitter is deterministic (derived from the attempt number via a + shift-based mix) so the same attempt always yields the same delay while + successive attempts spread their backoffs — no randomness dependency. + `base_delay` (the raw exponential core) remains public. ### Changed @@ -432,6 +445,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/2.0.0. ### Removed +- `StreamHandler::with_config(timeout, retry)` — set each config + independently via + [`with_timeout_config`](crate::stream::handler::StreamHandler::with_timeout_config) + and + [`with_retry_config`](crate::stream::handler::StreamHandler::with_retry_config) + instead. The coupled builder forced both configs to be passed in lockstep + even when only one changed; the two new builders each validate their own + config (an invalid value is logged and falls back to the default). - `FallbackManager::record_api_failure` and `FallbackManager::record_model_failure` — merged into a single [`FallbackManager::record_failure(FailureKind)`](crate::fallback::FallbackManager::record_failure). @@ -535,6 +556,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/2.0.0. `build_tool_result_message` is renamed to `build_tool_result_parts` and now returns `Vec` (it no longer wraps the parts in a throwaway `Message`). +- `StreamHandler` silently accepted invalid timeout and retry configs. + `StreamTimeoutConfig::validate` and `StreamRetryConfig::validate` + existed but were never called in production — only + `RateLimitConfig::validate` was wired into its builder. A + misconfiguration that disabled timeouts or inverted the retry ceiling + (e.g. `total_stream_timeout` less than `initial_event_timeout`, zero + delays) was stored as-is. The new `with_timeout_config` and + `with_retry_config` builders validate each config and fall back to the + default on an invalid value. +- `StreamRetryConfig::jitter_factor` was validated but never applied — + the transport-retry backoff used the raw exponential delay with no + jitter, so concurrent retries landed on the same tick (thundering herd). + `jittered_base_delay` now applies the factor and is the delay + `StreamHandler` sleeps between attempts. ### Security diff --git a/src/engine/bare.rs b/src/engine/bare.rs index 5cf0114..9f5f0ac 100644 --- a/src/engine/bare.rs +++ b/src/engine/bare.rs @@ -645,12 +645,11 @@ impl BareLoop { /// ```rust,ignore /// use loopctl::stream::handler::{StreamHandler, StreamTimeoutConfig}; /// - /// let handler = StreamHandler::with_config( + /// let handler = StreamHandler::new().with_timeout_config( /// StreamTimeoutConfig { /// initial_event_timeout: Duration::from_secs(60), /// ..Default::default() /// }, - /// Default::default(), /// ); /// /// let mut agent = BareLoop::new(client, registry, config); @@ -5026,9 +5025,7 @@ mod tests { async fn test_rate_limit_escalation_feeds_circuit_breaker() { use crate::fallback::FallbackManager; use crate::managers::LoopManagers; - use crate::stream::handler::{ - RateLimitConfig, StreamHandler, StreamRetryConfig, StreamTimeoutConfig, - }; + use crate::stream::handler::{RateLimitConfig, StreamHandler, StreamTimeoutConfig}; // Every stream attempt is rate-limited, so the handler escalates on the // first 429 (fallback_after_retries = 0). @@ -5058,13 +5055,10 @@ mod tests { } let handler = StreamHandler::new() - .with_config( - StreamTimeoutConfig { - fallback_to_non_streaming: false, - ..Default::default() - }, - StreamRetryConfig::default(), - ) + .with_timeout_config(StreamTimeoutConfig { + fallback_to_non_streaming: false, + ..Default::default() + }) .with_rate_limit_config(RateLimitConfig { fallback_after_retries: 0, default_delay: Duration::from_millis(1), diff --git a/src/stream/handler.rs b/src/stream/handler.rs index 622ddf2..0d22ddf 100644 --- a/src/stream/handler.rs +++ b/src/stream/handler.rs @@ -25,12 +25,11 @@ //! let handler = StreamHandler::new(); //! //! // Or with custom config: -//! let handler = StreamHandler::new().with_config( +//! let handler = StreamHandler::new().with_timeout_config( //! StreamTimeoutConfig { //! initial_event_timeout: std::time::Duration::from_secs(60), //! ..Default::default() //! }, -//! Default::default(), //! ); //! ``` @@ -101,12 +100,6 @@ pub struct StreamTimeoutConfig { /// threshold is used: `min(2, max_consecutive_timeouts)`. pub max_consecutive_timeouts: u32, - /// Interval for progress callbacks during long streams. - /// - /// The handler calls the progress callback at this interval to report - /// elapsed time and event count. - pub progress_interval: Duration, - /// Whether to fall back to [`ApiClient::create_message`] /// when streaming exhausts all retries. /// @@ -122,7 +115,6 @@ impl Default for StreamTimeoutConfig { per_event_timeout: Duration::from_mins(5), total_stream_timeout: Duration::from_mins(15), max_consecutive_timeouts: 10, - progress_interval: Duration::from_secs(30), fallback_to_non_streaming: true, } } @@ -168,9 +160,6 @@ impl StreamTimeoutConfig { self.total_stream_timeout, self.initial_event_timeout )); } - if self.progress_interval.is_zero() { - return Err("progress_interval must be non-zero".to_string()); - } if self.max_consecutive_timeouts == 0 { return Err("max_consecutive_timeouts must be >= 1".to_string()); @@ -243,8 +232,10 @@ impl StreamRetryConfig { /// Calculate the backoff delay for a given attempt number (0-indexed). /// /// Returns the delay as a [`Duration`], capped at - /// [`max_delay_ms`](Self::max_delay_ms). Does not apply jitter — - /// callers should add jitter based on [`jitter_factor`](Self::jitter_factor). + /// [`max_delay_ms`](Self::max_delay_ms). This is the *raw* exponential + /// backoff with no jitter; for the jittered delay used by + /// [`StreamHandler`] on transport retries, use + /// [`jittered_base_delay`](Self::jittered_base_delay). /// /// # Example /// @@ -265,6 +256,57 @@ impl StreamRetryConfig { Duration::from_millis(delay_ms.min(self.max_delay_ms)) } + /// The raw exponential backoff with [`jitter_factor`](Self::jitter_factor) applied. + /// + /// Returns [`base_delay`](Self::base_delay)`(attempt)` scaled by a + /// deterministic factor in `[1 - jitter_factor, 1 + jitter_factor]`. The + /// factor is derived from the attempt number via a shift-based mix, so the + /// same attempt always yields the same delay (reproducible in tests) while + /// successive attempts still spread their backoffs — avoiding a + /// thundering herd where every retry lands on the same tick. When + /// [`jitter_factor`](Self::jitter_factor) is `0.0`, returns + /// [`base_delay`](Self::base_delay) unchanged. + /// + /// This is the delay [`StreamHandler`] sleeps between transport-retry + /// attempts; [`base_delay`](Self::base_delay) is the deterministic core + /// it composes on. + /// + /// # Example + /// + /// ```rust + /// use loopctl::stream::handler::StreamRetryConfig; + /// use std::time::Duration; + /// + /// let config = StreamRetryConfig { jitter_factor: 0.0, ..Default::default() }; + /// // With no jitter, the jittered delay equals the raw backoff exactly. + /// assert_eq!(config.jittered_base_delay(1), config.base_delay(1)); + /// ``` + #[must_use] + pub fn jittered_base_delay(&self, attempt: u32) -> Duration { + let base = self.base_delay(attempt); + if self.jitter_factor == 0.0 { + return base; + } + let f = Self::jitter_fraction(attempt) * self.jitter_factor; + base.mul_f64(1.0 + f) + } + + /// A deterministic signed fraction in `[-1.0, 1.0)` derived from `attempt`. + /// + /// Shifts and mixes the attempt bits so successive attempts map to + /// well-spread fractions; the same attempt always yields the same value. + /// Used by [`jittered_base_delay`](Self::jittered_base_delay) to scale + /// the backoff without pulling in a randomness dependency. + #[must_use] + fn jitter_fraction(attempt: u32) -> f64 { + let mixed = attempt + .wrapping_mul(2_654_435_761) + .rotate_left(13) + .wrapping_add(0x9E37_79B9); + let scaled = f64::from(mixed >> 8) / f64::from(1u32 << 24); + (scaled - 0.5) * 2.0 + } + /// Validates the configuration, returning an error message if invalid. /// /// Checks that `jitter_factor` is finite and within `0.0..=1.0`, @@ -708,6 +750,29 @@ async fn sleep_cancellable( } } +/// A future that completes at `deadline`, or never if it is `None`. +/// +/// Shared by the deadline-driven arms of +/// [`next_event`](StreamHandler::next_event)'s `tokio::select!` (the +/// per-event timeout and the total-stream deadline). Each arm computes its +/// [`Option`] deadline and hands it here, so this function owns the +/// single definition of "sleep until the instant, or stay pending forever +/// when disabled." +/// +/// `None` disables the arm: the returned future never resolves, so the +/// `select!` branch stays inert. This is how +/// [`passthrough`](StreamHandler::passthrough) (which sets every timeout to +/// [`Duration::MAX`], yielding `None` deadlines) disables resilience without +/// a separate code path. A `Some(deadline)` already in the past resolves +/// immediately, letting a lapsed deadline fire on the next poll rather than +/// being missed. +async fn deadline_future(deadline: Option) { + match deadline { + Some(deadline) => tokio::time::sleep_until(deadline.into()).await, + None => std::future::pending::<()>().await, + } +} + /// Result of polling the stream once inside [`StreamHandler::next_event`]. /// /// Produced by the `tokio::select!` that races the stream against the @@ -1166,44 +1231,6 @@ impl fmt::Display for StreamHandlerError { impl std::error::Error for StreamHandlerError {} -/// A snapshot of stream progress for external reporting. -/// -/// Plain data struct carrying the two progress signals a consumer is likely -/// to want (elapsed time and events processed). `StreamHandler` does not -/// itself emit `StreamProgress` — it has no built-in progress callback. The -/// struct is shipped so a downstream consumer that drives its own progress -/// reporting (metrics observer, TUI heartbeat, deadline watcher) has a -/// shared shape to read or fill. -/// -/// # Example -/// -/// ```rust -/// use loopctl::stream::handler::StreamProgress; -/// use std::time::Duration; -/// -/// let progress = StreamProgress { -/// elapsed: Duration::from_secs(45), -/// events_processed: 127, -/// }; -/// assert_eq!(progress.events_processed, 127); -/// ``` -#[derive(Debug, Clone)] -pub struct StreamProgress { - /// Time elapsed since the stream started. - /// - /// Wall-clock duration from stream open to the snapshot point. Useful for - /// heartbeat-style reporting (“still streaming after Ns”) and for - /// deadline-aware consumers that compare it against their own budget. - pub elapsed: Duration, - - /// Number of SSE events processed so far. - /// - /// Count of stream events successfully accumulated up to the snapshot - /// point. A flat or slow-growing count is the early signal of a stalled - /// stream before a timeout fires. - pub events_processed: u64, -} - /// Holds configuration for the streaming resilience layer. /// /// `StreamHandler` wraps an [`ApiClient`]'s streaming path with timeout, @@ -1244,12 +1271,11 @@ pub struct StreamProgress { /// let handler = StreamHandler::new(); /// assert_eq!(handler.timeout_config().initial_event_timeout, std::time::Duration::from_secs(120)); /// -/// let handler = StreamHandler::new().with_config( +/// let handler = StreamHandler::new().with_timeout_config( /// StreamTimeoutConfig { /// initial_event_timeout: std::time::Duration::from_secs(60), /// ..Default::default() /// }, -/// Default::default(), /// ); /// assert_eq!(handler.timeout_config().initial_event_timeout, std::time::Duration::from_secs(60)); /// ``` @@ -1328,12 +1354,11 @@ impl StreamHandler { /// ```rust,no_run /// # use loopctl::stream::handler::{StreamHandler, StreamTimeoutConfig}; /// let handler = StreamHandler::passthrough() - /// .with_config( + /// .with_timeout_config( /// StreamTimeoutConfig { /// total_stream_timeout: std::time::Duration::from_secs(60), /// ..Default::default() /// }, - /// Default::default(), /// ); /// ``` /// @@ -1352,7 +1377,6 @@ impl StreamHandler { total_stream_timeout: NEVER_TIME_OUT, // validate() rejects 0; value is irrelevant since timeouts never fire. max_consecutive_timeouts: 1, - progress_interval: NEVER_TIME_OUT, fallback_to_non_streaming: false, }, retry_config: StreamRetryConfig { @@ -1408,19 +1432,47 @@ impl StreamHandler { } } - /// Create a handler with custom configuration. + /// Set the timeout configuration, consuming `self`. + /// + /// Validates `timeout`: if it violates any constraint, the invalid + /// value is logged and the default config is kept instead. See + /// [`StreamTimeoutConfig::validate`] for the constraints enforced. /// /// # Example /// /// ```rust - /// use loopctl::stream::handler::{StreamHandler, StreamTimeoutConfig, StreamRetryConfig}; + /// use loopctl::stream::handler::{StreamHandler, StreamTimeoutConfig}; /// use std::time::Duration; /// - /// let handler = StreamHandler::new().with_config( + /// let handler = StreamHandler::new().with_timeout_config( /// StreamTimeoutConfig { /// initial_event_timeout: Duration::from_secs(60), /// ..Default::default() /// }, + /// ); + /// ``` + #[must_use] + pub fn with_timeout_config(mut self, timeout: StreamTimeoutConfig) -> Self { + if let Err(e) = timeout.validate() { + tracing::warn!(error = %e, "invalid StreamTimeoutConfig, falling back to default"); + } else { + self.timeout_config = timeout; + } + self + } + + /// Set the retry configuration, consuming `self`. + /// + /// Validates `retry`: if it violates any constraint, the invalid + /// value is logged and the default config is kept instead. See + /// [`StreamRetryConfig::validate`] for the constraints enforced. + /// + /// # Example + /// + /// ```rust + /// use loopctl::stream::handler::{StreamHandler, StreamRetryConfig}; + /// + /// let handler = StreamHandler::new().with_retry_config( /// StreamRetryConfig { /// max_retries: 5, /// ..Default::default() @@ -1428,17 +1480,20 @@ impl StreamHandler { /// ); /// ``` #[must_use] - pub fn with_config(mut self, timeout: StreamTimeoutConfig, retry: StreamRetryConfig) -> Self { - self.timeout_config = timeout; - self.retry_config = retry; + pub fn with_retry_config(mut self, retry: StreamRetryConfig) -> Self { + if let Err(e) = retry.validate() { + tracing::warn!(error = %e, "invalid StreamRetryConfig, falling back to default"); + } else { + self.retry_config = retry; + } self } /// Returns a reference to the timeout configuration. /// /// Read-only access to the [`StreamTimeoutConfig`] stored on the handler. - /// Mutate via [`with_config`](StreamHandler::with_config) (which replaces - /// both timeout and retry together); there is no per-field setter. + /// Mutate via + /// [`with_timeout_config`](Self::with_timeout_config). #[must_use] pub fn timeout_config(&self) -> &StreamTimeoutConfig { &self.timeout_config @@ -1447,7 +1502,8 @@ impl StreamHandler { /// Returns a reference to the retry configuration. /// /// Read-only access to the [`StreamRetryConfig`] stored on the handler. - /// Mutate via [`with_config`](StreamHandler::with_config) (which replaces + /// Mutate via + /// [`with_retry_config`](Self::with_retry_config). /// both retry and timeout together); there is no per-field setter. #[must_use] pub fn retry_config(&self) -> &StreamRetryConfig { @@ -1668,7 +1724,7 @@ impl StreamHandler { Err(err)?; return; } - let delay = self.retry_config.base_delay(transport_attempts); + let delay = self.retry_config.jittered_base_delay(transport_attempts); transport_attempts = transport_attempts.saturating_add(1); sleep_cancellable(delay, cancel).await?; continue 'outer; @@ -1824,13 +1880,8 @@ impl StreamHandler { let event_result = tokio::select! { event = stream.next() => EventPoll::Next(event), () = cancel.notified() => return Err(StreamHandlerError::Cancelled), - () = async { - match event_deadline { - Some(d) => tokio::time::sleep_until(d.into()).await, - None => std::future::pending::<()>().await, - } - } => EventPoll::TimedOut, - () = Self::total_deadline_future(total_deadline) => { + () = deadline_future(event_deadline) => EventPoll::TimedOut, + () = deadline_future(total_deadline) => { return Err(StreamHandlerError::StreamFailed(diagnostics.total_timeout())); } }; @@ -1853,15 +1904,17 @@ impl StreamHandler { } } - /// The deadline for the next stream event, or `None` if per-event - /// timeouts are disabled (the timeout is `Duration::MAX`, which - /// overflows `Instant::now() + it`). + /// The deadline for the next stream event, or `None` if disabled. /// - /// Uses [`initial_event_timeout`](StreamTimeoutConfig::initial_event_timeout) + /// Computes the instant at which the per-event timeout fires for the + /// current poll: [`initial_event_timeout`](StreamTimeoutConfig::initial_event_timeout) /// before any event has arrived (the model may need time to begin - /// generating), then switches to - /// [`per_event_timeout`](StreamTimeoutConfig::per_event_timeout) once - /// events are flowing. + /// generating), then [`per_event_timeout`](StreamTimeoutConfig::per_event_timeout) + /// once events are flowing. A disabled timeout ([`Duration::MAX`]) + /// overflows `Instant::now() + timeout`, so `checked_add` returns `None` + /// and the caller arms a never-firing `select!` branch. `events_processed` + /// is the same counter [`next_event`](Self::next_event) maintains, so the + /// deadline always matches the timeout phase the stream is in. fn event_deadline(&self, events_processed: u64) -> Option { let base_timeout = if events_processed == 0 { self.timeout_config.initial_event_timeout @@ -1877,9 +1930,9 @@ impl StreamHandler { /// loop, before the per-event `select!` commits to another wait. This /// catches a deadline that elapsed while the loop was processing the /// previous event (or building diagnostics) — the - /// [`total_deadline_future`](Self::total_deadline_future) `select!` arm - /// only fires *during* a wait, so without this check a long event handler - /// could overshoot the deadline by up to one event's processing time. + /// [`deadline_future`] `select!` arm only fires *during* a wait, so + /// without this check a long event handler could overshoot the deadline + /// by up to one event's processing time. /// /// `None` means no total-stream deadline is configured (the turn is /// bounded only by the per-event timeout) and the function returns @@ -1891,21 +1944,6 @@ impl StreamHandler { } } - /// A future that completes when the overall total-stream deadline elapses. - /// - /// Returns a future that never resolves when there is no total deadline, - /// so the `tokio::select!` branch stays inert in that case. - async fn total_deadline_future(total_deadline: Option) { - match total_deadline { - Some(deadline) => { - if let Some(duration) = deadline.checked_duration_since(Instant::now()) { - tokio::time::sleep(duration).await; - } - } - None => std::future::pending::<()>().await, - } - } - /// Fall back to non-streaming message creation. /// /// Called when streaming fails (timeout, retries exhausted) and @@ -2116,7 +2154,6 @@ mod tests { assert_eq!(config.per_event_timeout, Duration::from_mins(5)); assert_eq!(config.total_stream_timeout, Duration::from_mins(15)); assert_eq!(config.max_consecutive_timeouts, 10); - assert_eq!(config.progress_interval, Duration::from_secs(30)); assert!(config.fallback_to_non_streaming); } @@ -2149,7 +2186,6 @@ mod tests { per_event_timeout: Duration::from_mins(1), total_stream_timeout: Duration::from_mins(5), max_consecutive_timeouts: 5, - progress_interval: Duration::from_secs(10), fallback_to_non_streaming: false, }; assert_eq!(config.initial_event_timeout, Duration::from_secs(30)); @@ -2181,10 +2217,77 @@ mod tests { max_delay_ms: 5000, ..Default::default() }; - // 1000 * 2^3 = 8000, capped at 5000 assert_eq!(config.base_delay(3), Duration::from_secs(5)); } + #[test] + fn jittered_base_delay_zero_jitter_equals_raw() { + let config = StreamRetryConfig { + jitter_factor: 0.0, + ..Default::default() + }; + for attempt in 0..5 { + assert_eq!( + config.jittered_base_delay(attempt), + config.base_delay(attempt), + "zero jitter must reproduce the raw backoff exactly" + ); + } + } + + #[test] + fn jittered_base_delay_stays_within_jitter_band() { + let config = StreamRetryConfig { + base_delay_ms: 100, + max_delay_ms: 100_000, + jitter_factor: 0.2, + ..Default::default() + }; + for attempt in 0..64 { + let base = config.base_delay(attempt); + let delay = config.jittered_base_delay(attempt); + let lo = base.mul_f64(0.8); + let hi = base.mul_f64(1.2); + assert!( + delay >= lo && delay <= hi, + "attempt {attempt}: jittered delay {delay:?} outside [{lo:?}, {hi:?}]" + ); + } + } + + #[test] + fn jittered_base_delay_is_deterministic() { + let config = StreamRetryConfig { + jitter_factor: 0.3, + ..Default::default() + }; + for attempt in 0..16 { + assert_eq!( + config.jittered_base_delay(attempt), + config.jittered_base_delay(attempt), + "jitter must be deterministic per attempt" + ); + } + } + + #[test] + fn jittered_base_delay_max_jitter_stays_non_negative() { + let config = StreamRetryConfig { + base_delay_ms: 100, + max_delay_ms: 100_000, + jitter_factor: 1.0, + ..Default::default() + }; + for attempt in 0..256 { + let delay = config.jittered_base_delay(attempt); + let hi = config.base_delay(attempt).mul_f64(2.0); + assert!( + delay <= hi, + "attempt {attempt}: delay {delay:?} exceeds 2x base under max jitter" + ); + } + } + #[test] fn outcome_completed_display() { let outcome = StreamOutcome::Completed { @@ -2302,16 +2405,6 @@ mod tests { ); } - #[test] - fn progress_fields() { - let progress = StreamProgress { - elapsed: Duration::from_secs(45), - events_processed: 127, - }; - assert_eq!(progress.elapsed, Duration::from_secs(45)); - assert_eq!(progress.events_processed, 127); - } - #[test] fn handler_new_defaults() { let handler = StreamHandler::new(); @@ -2323,17 +2416,16 @@ mod tests { } #[test] - fn handler_with_config() { - let handler = StreamHandler::new().with_config( - StreamTimeoutConfig { + fn handler_with_timeout_and_retry_config() { + let handler = StreamHandler::new() + .with_timeout_config(StreamTimeoutConfig { initial_event_timeout: Duration::from_mins(1), ..Default::default() - }, - StreamRetryConfig { + }) + .with_retry_config(StreamRetryConfig { max_retries: 5, ..Default::default() - }, - ); + }); assert_eq!( handler.timeout_config().initial_event_timeout, Duration::from_mins(1), @@ -2405,16 +2497,6 @@ mod tests { assert!(err.contains("initial_event_timeout")); } - #[test] - fn timeout_config_validate_zero_progress() { - let config = StreamTimeoutConfig { - progress_interval: Duration::ZERO, - ..Default::default() - }; - let err = config.validate().unwrap_err(); - assert!(err.contains("progress_interval")); - } - #[test] fn retry_config_validate_default_ok() { assert!(StreamRetryConfig::default().validate().is_ok()); @@ -2606,13 +2688,10 @@ mod tests { #[tokio::test] async fn fallback_non_streaming_success() { - let handler = StreamHandler::new().with_config( - StreamTimeoutConfig { - fallback_to_non_streaming: true, - ..Default::default() - }, - StreamRetryConfig::default(), - ); + let handler = StreamHandler::new().with_timeout_config(StreamTimeoutConfig { + fallback_to_non_streaming: true, + ..Default::default() + }); let client = HandlerMock::new().with_text_response("fallback works"); let cancel = Arc::new(CancelSignal::new()); @@ -2646,13 +2725,10 @@ mod tests { #[tokio::test] async fn fallback_non_streaming_cancelled_before_start() { - let handler = StreamHandler::new().with_config( - StreamTimeoutConfig { - fallback_to_non_streaming: true, - ..Default::default() - }, - StreamRetryConfig::default(), - ); + let handler = StreamHandler::new().with_timeout_config(StreamTimeoutConfig { + fallback_to_non_streaming: true, + ..Default::default() + }); let client = HandlerMock::new().with_text_response("fallback works"); let cancel = Arc::new(CancelSignal::new()); cancel.cancel(); @@ -2675,13 +2751,10 @@ mod tests { #[tokio::test] async fn fallback_non_streaming_error() { - let handler = StreamHandler::new().with_config( - StreamTimeoutConfig { - fallback_to_non_streaming: true, - ..Default::default() - }, - StreamRetryConfig::default(), - ); + let handler = StreamHandler::new().with_timeout_config(StreamTimeoutConfig { + fallback_to_non_streaming: true, + ..Default::default() + }); let client = HandlerMock::new().with_create_error("service unavailable"); let cancel = Arc::new(CancelSignal::new()); @@ -2920,16 +2993,15 @@ mod tests { } } - let handler = StreamHandler::new().with_config( - StreamTimeoutConfig { + let handler = StreamHandler::new() + .with_timeout_config(StreamTimeoutConfig { fallback_to_non_streaming: false, ..Default::default() - }, - StreamRetryConfig { + }) + .with_retry_config(StreamRetryConfig { max_retries: 0, ..Default::default() - }, - ); + }); let client = ErrorMock; let cancel = Arc::new(CancelSignal::new()); @@ -3022,16 +3094,15 @@ mod tests { // non-streaming JSON response (not the streaming accumulator's stale // values). Regression test for an earlier bug where Fallback dropped // stop_reason. - let handler = StreamHandler::new().with_config( - StreamTimeoutConfig { + let handler = StreamHandler::new() + .with_timeout_config(StreamTimeoutConfig { fallback_to_non_streaming: true, ..Default::default() - }, - StreamRetryConfig { + }) + .with_retry_config(StreamRetryConfig { max_retries: 0, ..Default::default() - }, - ); + }); let client = StreamingFailingFallbackMock; let cancel = Arc::new(CancelSignal::new()); @@ -3110,6 +3181,96 @@ mod tests { ); } + #[test] + fn with_timeout_config_rejects_invalid_falls_back_to_default() { + let bad = StreamTimeoutConfig { + initial_event_timeout: Duration::ZERO, + ..Default::default() + }; + let handler = StreamHandler::new().with_timeout_config(bad); + assert_eq!( + handler.timeout_config().initial_event_timeout, + StreamTimeoutConfig::default().initial_event_timeout, + "invalid timeout config must fall back to default" + ); + } + + #[test] + fn with_timeout_config_keeps_valid() { + let good = StreamTimeoutConfig { + initial_event_timeout: Duration::from_secs(45), + ..Default::default() + }; + let handler = StreamHandler::new().with_timeout_config(good); + assert_eq!( + handler.timeout_config().initial_event_timeout, + Duration::from_secs(45) + ); + } + + #[test] + fn with_retry_config_rejects_invalid_falls_back_to_default() { + let bad = StreamRetryConfig { + base_delay_ms: 0, + ..Default::default() + }; + let handler = StreamHandler::new().with_retry_config(bad); + assert_eq!( + handler.retry_config().base_delay_ms, + StreamRetryConfig::default().base_delay_ms, + "invalid retry config must fall back to default" + ); + } + + #[test] + fn with_retry_config_keeps_valid() { + let good = StreamRetryConfig { + max_retries: 7, + ..Default::default() + }; + let handler = StreamHandler::new().with_retry_config(good); + assert_eq!(handler.retry_config().max_retries, 7); + } + + #[test] + fn with_timeout_and_retry_config_are_independent() { + let good_timeout = StreamTimeoutConfig { + initial_event_timeout: Duration::from_mins(1), + ..Default::default() + }; + let bad_retry = StreamRetryConfig { + jitter_factor: 2.0, + ..Default::default() + }; + let handler = StreamHandler::new() + .with_timeout_config(good_timeout) + .with_retry_config(bad_retry); + assert_eq!( + handler.timeout_config().initial_event_timeout, + Duration::from_mins(1), + "valid timeout must be kept when retry config is invalid" + ); + assert_eq!( + handler.retry_config().max_retries, + StreamRetryConfig::default().max_retries, + "invalid retry config must fall back to default" + ); + } + + #[test] + fn with_rate_limit_config_rejects_invalid_falls_back_to_default() { + let bad = RateLimitConfig { + max_retries: 0, + ..Default::default() + }; + let handler = StreamHandler::new().with_rate_limit_config(bad); + assert_eq!( + handler.rate_limit_config().max_retries, + RateLimitConfig::default().max_retries, + "invalid rate-limit config must fall back to default" + ); + } + #[test] fn rate_limit_config_backoff_honours_hint_and_caps() { let cfg = RateLimitConfig::default(); @@ -3716,14 +3877,11 @@ mod tests { // Rate-limit delay tiny; transport retry delay large. If the retry // loop honours the rate-limit outcome, the test finishes in ~1ms; if // it falls back to the transport base_delay, it sleeps 2s. - let handler = StreamHandler::new().with_config( - StreamTimeoutConfig::default(), - StreamRetryConfig { - max_retries: 1, - base_delay_ms: 2_000, - ..Default::default() - }, - ); + let handler = StreamHandler::new().with_retry_config(StreamRetryConfig { + max_retries: 1, + base_delay_ms: 2_000, + ..Default::default() + }); let handler = handler.with_rate_limit_config(RateLimitConfig { default_delay: Duration::from_millis(1), ..Default::default() @@ -3858,17 +4016,15 @@ mod tests { } let handler = StreamHandler::new() - .with_config( - StreamTimeoutConfig { - fallback_to_non_streaming: false, - ..Default::default() - }, - StreamRetryConfig { - max_retries: 1, - base_delay_ms: 1, - ..Default::default() - }, - ) + .with_timeout_config(StreamTimeoutConfig { + fallback_to_non_streaming: false, + ..Default::default() + }) + .with_retry_config(StreamRetryConfig { + max_retries: 1, + base_delay_ms: 1, + ..Default::default() + }) .with_rate_limit_config(RateLimitConfig { fallback_after_retries: 3, default_delay: Duration::from_millis(1), @@ -4060,17 +4216,16 @@ mod tests { } } - let handler = StreamHandler::new().with_config( - StreamTimeoutConfig { + let handler = StreamHandler::new() + .with_timeout_config(StreamTimeoutConfig { fallback_to_non_streaming: false, ..Default::default() - }, - StreamRetryConfig { + }) + .with_retry_config(StreamRetryConfig { max_retries: 1, base_delay_ms: 1, ..Default::default() - }, - ); + }); let client = AlwaysFailingMock; let cancel = Arc::new(CancelSignal::new()); @@ -4121,17 +4276,17 @@ mod tests { } let handler = StreamHandler::new() - .with_config( - StreamTimeoutConfig { - total_stream_timeout: Duration::from_millis(80), - ..Default::default() - }, - StreamRetryConfig { - max_retries: 10, - base_delay_ms: 1, - ..Default::default() - }, - ) + .with_timeout_config(StreamTimeoutConfig { + initial_event_timeout: Duration::from_millis(40), + per_event_timeout: Duration::from_millis(40), + total_stream_timeout: Duration::from_millis(80), + ..Default::default() + }) + .with_retry_config(StreamRetryConfig { + max_retries: 10, + base_delay_ms: 1, + ..Default::default() + }) .with_rate_limit_config(RateLimitConfig { // Honour the hint, but max_delay lets the 600s through so the // deadline clamp is what must bound the sleep. @@ -4194,14 +4349,11 @@ mod tests { } } - let handler = StreamHandler::new().with_config( - StreamTimeoutConfig::default(), - StreamRetryConfig { - max_retries: 5, - base_delay_ms: 60_000, - ..Default::default() - }, - ); + let handler = StreamHandler::new().with_retry_config(StreamRetryConfig { + max_retries: 5, + base_delay_ms: 60_000, + ..Default::default() + }); let cancel = Arc::new(CancelSignal::new()); let cancel_clone = Arc::clone(&cancel); tokio::spawn(async move { From 6ff617f65ee2c623aa3e55b4408bb25c1329be5a Mon Sep 17 00:00:00 2001 From: Iurii Bobrykov Date: Thu, 30 Jul 2026 13:42:57 +1200 Subject: [PATCH 04/20] fix: fast fail for empty stream --- CHANGELOG.md | 7 ++++ src/stream/handler.rs | 76 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd0b0cc..abb8e06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -570,6 +570,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/2.0.0. jitter, so concurrent retries landed on the same tick (thundering herd). `jittered_base_delay` now applies the factor and is the delay `StreamHandler` sleeps between attempts. +- The empty-stream fast-fail promised in the `max_consecutive_timeouts` + docs was never implemented: `next_event` applied the full threshold + unconditionally, so a stream that never produced a single event could + hang for `max_consecutive_timeouts` × `initial_event_timeout` (~20 min + with defaults) before failing. The lower threshold (`min(2, + max_consecutive_timeouts)`) is now applied when zero events have been + received, matching the documented behavior. ### Security diff --git a/src/stream/handler.rs b/src/stream/handler.rs index 0d22ddf..5a3c0e2 100644 --- a/src/stream/handler.rs +++ b/src/stream/handler.rs @@ -1888,7 +1888,11 @@ impl StreamHandler { match event_result { EventPoll::TimedOut => { *consecutive_timeouts = consecutive_timeouts.saturating_add(1); - let max_consecutive = self.timeout_config.max_consecutive_timeouts as usize; + let max_consecutive = if diagnostics.events_processed == 0 { + self.timeout_config.max_consecutive_timeouts.min(2) as usize + } else { + self.timeout_config.max_consecutive_timeouts as usize + }; if *consecutive_timeouts >= max_consecutive { return Err(StreamHandlerError::StreamFailed(diagnostics.event_timeout( u32::try_from(*consecutive_timeouts).unwrap_or(u32::MAX), @@ -2820,6 +2824,76 @@ mod tests { assert!(!saw_fallback, "happy path must not emit Fallback"); } + #[tokio::test] + async fn empty_stream_fast_fails_after_lower_threshold() { + struct NeverYieldingMock; + impl ApiClient for NeverYieldingMock { + fn model(&self) -> String { + "stuck".to_string() + } + fn stream_messages( + &self, + _request: &crate::api::StreamRequest, + ) -> std::pin::Pin< + Box> + Send + 'static>, + > { + Box::pin(futures::stream::pending()) + } + fn create_message( + &self, + _request: &crate::api::StreamRequest, + ) -> std::pin::Pin< + Box< + dyn std::future::Future> + + Send + + '_, + >, + > { + Box::pin(async { Ok(serde_json::json!({})) }) + } + } + + let handler = StreamHandler::new() + .with_timeout_config(StreamTimeoutConfig { + initial_event_timeout: Duration::from_millis(10), + per_event_timeout: Duration::from_millis(10), + total_stream_timeout: Duration::from_secs(10), + max_consecutive_timeouts: 10, + fallback_to_non_streaming: false, + }) + .with_retry_config(StreamRetryConfig { + max_retries: 0, + ..Default::default() + }); + let client = NeverYieldingMock; + let cancel = Arc::new(CancelSignal::new()); + let req = crate::api::StreamRequest::new(vec![]); + let mut stream = handler.stream_turn( + &client, + &req, + crate::structured::RequestOptions::default(), + &cancel, + ); + let start = Instant::now(); + let mut got = None; + while let Some(item) = stream.next().await { + if item.is_err() { + got = Some(item); + break; + } + } + let elapsed = start.elapsed(); + match got.expect("stream must terminate with an error") { + Err(StreamHandlerError::StreamFailed(StreamOutcome::EventTimeout { .. })) => {} + other => panic!("expected EventTimeout on dead stream, got {other:?}"), + } + assert!( + elapsed < Duration::from_millis(60), + "empty-stream fast-fail (2×10ms) must beat the full threshold (10×10ms); \ + elapsed {elapsed:?}", + ); + } + /// Mock that fails its first streaming attempt with a transport error, /// then succeeds on the second. Used by the AttemptReset test to verify /// the handler emits `AttemptReset` before the retried attempt's events. From acdf6040cdc6f92d64f5f240ff7a8d33c1503d54 Mon Sep 17 00:00:00 2001 From: Iurii Bobrykov Date: Thu, 30 Jul 2026 14:27:12 +1200 Subject: [PATCH 05/20] fix: is_tool_available doesn't consume probe --- CHANGELOG.md | 8 ++++ src/tool/health.rs | 93 ++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 94 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index abb8e06..48571f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -577,6 +577,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/2.0.0. with defaults) before failing. The lower threshold (`min(2, max_consecutive_timeouts)`) is now applied when zero events have been received, matching the documented behavior. +- `ToolHealthRegistry::is_tool_available` consumed the HalfOpen recovery + probe as a side effect of the read: it called + [`allow_request`](ToolCircuitBreaker::allow_request), which performs the + Open→HalfOpen transition, so a bare availability check (or + [`resolve_tool`](HealthRouter::resolve_tool)) wasted the single probe slot + and blocked the real dispatch that followed. It now uses pure-read + helpers (`would_allow_request` / `would_be_half_open`) that observe the + decision without the transition. ### Security diff --git a/src/tool/health.rs b/src/tool/health.rs index 82b1d78..f177a61 100644 --- a/src/tool/health.rs +++ b/src/tool/health.rs @@ -588,6 +588,49 @@ impl ToolCircuitBreaker { } } + /// Whether a request *would* be allowed, without the `Open`→`HalfOpen` side effect. + /// + /// Pure read mirroring [`allow_request`](Self::allow_request)'s decision + /// logic: returns `true` for `Closed`, `false` for `HalfOpen`, and for + /// `Open` returns `true` only if the recovery duration has elapsed (i.e. + /// the next [`allow_request`](Self::allow_request) call would transition + /// to `HalfOpen` and grant the probe). Crucially, this performs **no** + /// state transition — use it for availability checks + /// ([`is_tool_available`](ToolHealthRegistry::is_tool_available)) so a + /// read does not consume the single `HalfOpen` probe slot that belongs to + /// the real dispatch path. + #[must_use] + pub fn would_allow_request(&self) -> bool { + let state = crate::error::recover_guard(self.state.lock()); + match state.circuit { + CircuitState::Closed => true, + CircuitState::HalfOpen => false, + CircuitState::Open => state + .last_failure_time + .is_some_and(|t| t.elapsed() >= self.recovery_duration), + } + } + + /// Whether the breaker would treat the next call as a `HalfOpen` probe. + /// + /// Pure read: `true` when the breaker is already `HalfOpen`, or when it + /// is `Open` but the recovery duration has elapsed (so the next + /// [`allow_request`](Self::allow_request) would transition to + /// `HalfOpen`). Lets an availability check report "a recovery probe is + /// pending" without performing the transition. Complements + /// [`would_allow_request`](Self::would_allow_request). + #[must_use] + pub fn would_be_half_open(&self) -> bool { + let state = crate::error::recover_guard(self.state.lock()); + match state.circuit { + CircuitState::HalfOpen => true, + CircuitState::Open => state + .last_failure_time + .is_some_and(|t| t.elapsed() >= self.recovery_duration), + CircuitState::Closed => false, + } + } + /// Record a successful call. /// /// Resets consecutive failures to zero and transitions the breaker @@ -809,18 +852,24 @@ impl ToolHealthRegistry { /// Quick health check: is this tool available for use? /// - /// Combines the circuit-breaker state (Open = unavailable) with the - /// health score (Unhealthy = unavailable). Returns `true` when: - /// - the breaker is not Open and the health score is not Unhealthy, or - /// - the breaker has transitioned to `HalfOpen` (allowing a recovery probe - /// even if the health score is still Unhealthy). + /// Combines the circuit-breaker state (`Open` = unavailable) with the + /// health score (`Unhealthy` = unavailable). Returns `true` when: + /// - the breaker is not `Open` and the health score is not `Unhealthy`, or + /// - the breaker would treat the next call as a `HalfOpen` recovery probe + /// (available even if the health score is still `Unhealthy`). + /// + /// This is a **pure read** — it observes whether a request would be + /// allowed without performing the `Open`→`HalfOpen` transition, so a + /// bare availability check does not consume the single probe slot that + /// belongs to the real dispatch path + /// ([`allow_request`](ToolCircuitBreaker::allow_request)). #[must_use] pub fn is_tool_available(&self, tool_name: &str) -> bool { let breaker = self.get_circuit_breaker(tool_name); - if !breaker.allow_request() { + if !breaker.would_allow_request() { return false; } - if breaker.is_half_open() { + if breaker.would_be_half_open() { return true; } self.get_health_status(tool_name) != HealthStatus::Unhealthy @@ -1343,6 +1392,36 @@ mod tests { assert!(!registry.is_tool_available("tool_b")); } + #[test] + fn is_tool_available_does_not_consume_half_open_probe() { + let registry = ToolHealthRegistry::new().with_config(CircuitBreakerConfig { + failure_threshold: 1, + recovery_duration: Duration::from_millis(40), + }); + registry.record_failure("tool", Duration::from_millis(1)); + assert!( + !registry.is_tool_available("tool"), + "Open breaker must be unavailable" + ); + std::thread::sleep(Duration::from_millis(50)); + + // An availability check on the recovered-Open breaker must report + // available (the next dispatch would probe) WITHOUT performing the + // Open→HalfOpen transition — otherwise the read consumes the probe + // and the real dispatch is blocked. + assert!( + registry.is_tool_available("tool"), + "recovered breaker must report available" + ); + let breaker = registry.get_circuit_breaker("tool"); + assert!( + breaker.allow_request(), + "is_tool_available must not consume the HalfOpen probe slot; \ + the real dispatch path must still get it" + ); + assert_eq!(breaker.state_label(), "half-open"); + } + #[test] fn registry_health_summary() { let registry = ToolHealthRegistry::new(); From 7851705906e946192596afc2dbedc5855df13920 Mon Sep 17 00:00:00 2001 From: Iurii Bobrykov Date: Thu, 30 Jul 2026 14:37:15 +1200 Subject: [PATCH 06/20] fix: anthropic provider text index --- CHANGELOG.md | 6 +++ src/provider/anthropic.rs | 80 ++++++++++++++++++++++++++++++--------- 2 files changed, 68 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48571f0..7fc2e13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -585,6 +585,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/2.0.0. and blocked the real dispatch that followed. It now uses pure-read helpers (`would_allow_request` / `would_be_half_open`) that observe the decision without the transition. +- The Anthropic provider hardcoded text content blocks to part index 0, + ignoring the server-supplied block index. Tool and thinking blocks used + the real index, so a response ordering like `[tool_use@0, text@1]` made + the text deltas collide with the tool-call's index 0. Text blocks now + track and emit at the server index (mirroring the tool/thinking lanes), + eliminating the collision. ### Security diff --git a/src/provider/anthropic.rs b/src/provider/anthropic.rs index 53d9a36..5ea8b49 100644 --- a/src/provider/anthropic.rs +++ b/src/provider/anthropic.rs @@ -41,7 +41,6 @@ const DEFAULT_MODEL: &str = "claude-sonnet-4-20250514"; const ANTHROPIC_VERSION: &str = "2023-06-01"; const SSE_EVENT_PREFIX: &str = "event: "; const SSE_DATA_PREFIX: &str = "data: "; -const TEXT_PART_INDEX: usize = 0; const DEFAULT_MAX_TOKENS: u32 = 8192; const MAX_ERROR_BODY: usize = 8 * 1024; // 8 Kb @@ -831,14 +830,17 @@ struct StreamEmitter { /// ignores any subsequent duplicates. started: bool, - /// Whether a text content block is currently open. + /// Index of the text content block currently open, if any. /// /// Anthropic signals the start of a text block with /// `content_block_start` (`type: "text"`) and its end with - /// `content_block_stop`. This flag tracks the open state so the - /// matching `content_block_stop` emits exactly one - /// [`StreamEvent::PartStop`]. - text_part_open: bool, + /// `content_block_stop`. This holds the server-supplied block index + /// while the block is open and `None` when no text block is open, so + /// the matching `content_block_stop` emits exactly one + /// [`StreamEvent::PartStop`] and `text_delta` fragments route to the + /// correct part. Mirrors [`current_tool_index`](Self::current_tool_index) + /// and [`thinking_index`](Self::thinking_index) for the text lane. + text_index: Option, /// Number of tool-use content blocks currently open. /// @@ -1001,9 +1003,9 @@ impl StreamEmitter { self.tool_parts_open = self.tool_parts_open.saturating_add(1); } Some("text") => { - self.text_part_open = true; + self.text_index = Some(index); self.push(StreamEvent::PartStart(PartStart { - index: TEXT_PART_INDEX, + index, part: Some(MessagePart::text("")), })); } @@ -1049,8 +1051,9 @@ impl StreamEmitter { .unwrap_or("") .to_string(); if !text.is_empty() { + let text_index = self.text_index.unwrap_or(0); self.push(StreamEvent::IndexedDelta(IndexedDelta { - index: TEXT_PART_INDEX, + index: text_index, delta: DeltaPart::Text { text }, })); } @@ -1063,7 +1066,7 @@ impl StreamEmitter { .to_string(); if !json.is_empty() { // Use the index from the corresponding content_block_start. - let tool_index = self.current_tool_index.unwrap_or(TEXT_PART_INDEX); + let tool_index = self.current_tool_index.unwrap_or(0); self.push(StreamEvent::IndexedDelta(IndexedDelta { index: tool_index, delta: DeltaPart::InputJson { partial_json: json }, @@ -1078,7 +1081,7 @@ impl StreamEmitter { .to_string(); if !text.is_empty() { // Use the index from the corresponding content_block_start. - let thinking_index = self.thinking_index.unwrap_or(TEXT_PART_INDEX); + let thinking_index = self.thinking_index.unwrap_or(0); self.push(StreamEvent::IndexedDelta(IndexedDelta { index: thinking_index, delta: DeltaPart::Thinking { text }, @@ -1099,8 +1102,8 @@ impl StreamEmitter { /// does not carry useful information on this event beyond the /// implicit close). fn on_block_stop(&mut self, _data: Option) { - if self.text_part_open { - self.text_part_open = false; + if self.text_index.is_some() { + self.text_index = None; self.push(StreamEvent::PartStop); } else if self.thinking_part_open { self.thinking_part_open = false; @@ -1183,7 +1186,7 @@ impl StreamEmitter { if self.thinking_part_open { self.push(StreamEvent::PartStop); } - if self.text_part_open { + if self.text_index.is_some() { self.push(StreamEvent::PartStop); } for _ in 0..self.tool_parts_open { @@ -1192,7 +1195,7 @@ impl StreamEmitter { self.thinking_part_open = false; self.thinking_index = None; self.tool_parts_open = 0; - self.text_part_open = false; + self.text_index = None; self.push(StreamEvent::MessageStop); } @@ -1611,6 +1614,47 @@ mod tests { assert!(matches!(events[0], StreamEvent::IndexedDelta(_))); } + #[test] + fn emitter_text_after_tool_uses_server_index_not_zero() { + let mut em = StreamEmitter::default(); + + // Tool-use block at server index 0. + em.on_block_start(Some(serde_json::json!({ + "index": 0, + "content_block": {"type": "tool_use", "id": "t1", "name": "echo"} + }))); + em.drain(); + + // Text block at server index 1 — must NOT collide with the tool at 0. + em.on_block_start(Some(serde_json::json!({ + "index": 1, + "content_block": {"type": "text"} + }))); + let starts = em.drain(); + let text_start = starts + .iter() + .find(|e| matches!(e, StreamEvent::PartStart(ps) if ps.index == 1)) + .expect("text PartStart must carry the server index 1"); + + // Text delta must route to index 1, not the hardcoded 0 that would + // collide with the tool-use part. + em.on_block_delta(Some(serde_json::json!({ + "delta": {"type": "text_delta", "text": "after"} + }))); + let deltas = em.drain(); + match &deltas[0] { + StreamEvent::IndexedDelta(d) => { + assert_eq!(d.index, 1, "text delta must use the server index, not 0"); + } + other => panic!("expected IndexedDelta, got {other:?}"), + } + // Confirm the PartStart we matched above really is a text part. + let StreamEvent::PartStart(ps) = text_start else { + panic!("matched event must be a PartStart"); + }; + assert!(ps.part.as_ref().is_some_and(crate::message::MessagePart::is_text)); + } + #[test] fn emitter_tool_use_block() { let mut em = StreamEmitter::default(); @@ -1638,12 +1682,12 @@ mod tests { #[test] fn emitter_block_stop_closes_text() { let mut em = StreamEmitter::default(); - em.text_part_open = true; + em.text_index = Some(0); em.on_block_stop(None); let events = em.drain(); assert!(matches!(events[0], StreamEvent::PartStop)); - assert!(!em.text_part_open); + assert!(em.text_index.is_none()); } #[test] @@ -1668,7 +1712,7 @@ mod tests { #[test] fn emitter_message_stop_closes_parts() { let mut em = StreamEmitter::default(); - em.text_part_open = true; + em.text_index = Some(0); em.tool_parts_open = 2; em.on_message_stop(); From f304bcb4da394bedae11c65e13dddfed07d37936 Mon Sep 17 00:00:00 2001 From: Iurii Bobrykov Date: Thu, 30 Jul 2026 14:52:12 +1200 Subject: [PATCH 07/20] fix: bound response body memory before full materialization --- CHANGELOG.md | 9 +++ src/provider.rs | 115 +++++++++++++++++++++++++++++++++++--- src/provider/anthropic.rs | 12 ++-- src/provider/gemini.rs | 12 +--- src/provider/openai.rs | 25 +-------- 5 files changed, 126 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fc2e13..052189f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -605,6 +605,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/2.0.0. populate `AutoCommitConfig::files` or rely on the hook's per-session modification tracking. Misconfiguration now fails loudly rather than silently committing everything. +- The `MAX_RESPONSE_BODY` guard (10 MB) on non-streaming provider responses + fired *after* the body was fully materialized — every provider called + `resp.bytes().await` then checked the length, so a hostile or misbehaving + server returning a multi-GB body could exhaust memory before the guard + rejected it. A shared `read_bounded_body` now pre-checks `Content-Length` + (rejecting without reading a byte when it exceeds the cap) and reads + chunked-transfer responses with a running cap, so peak memory never + exceeds the limit by more than one chunk. Replaces the post-hoc + `check_response_body` across OpenAI, Anthropic, and Gemini. ## [0.1.0] - 2025-07-01 diff --git a/src/provider.rs b/src/provider.rs index c848387..3c45492 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -58,6 +58,8 @@ use crate::api::error::ApiError; #[cfg(any(feature = "anthropic", feature = "gemini"))] use crate::message::{MessagePart, Role}; +#[cfg(any(feature = "openai", feature = "anthropic", feature = "gemini"))] +use futures::StreamExt; use std::time::Duration; // SSE line-framing shared by every streaming provider. Each provider keeps @@ -69,25 +71,57 @@ mod sse; /// Maximum accepted response body size (10 MB). /// /// Guards against unbounded memory growth from a misbehaving or hostile -/// provider that returns a very large non-streaming response. +/// provider that returns a very large non-streaming response. Enforced +/// *before* the body is fully materialized — see +/// [`read_bounded_body`](crate::provider::read_bounded_body). #[cfg(any(feature = "openai", feature = "anthropic", feature = "gemini"))] pub(super) const MAX_RESPONSE_BODY: usize = 10 * 1024 * 1024; -/// Reject a response body that exceeds [`MAX_RESPONSE_BODY`]. +/// Read a response body, rejecting it before peak memory is exceeded. +/// +/// Shared guard used by every provider's non-streaming path. Two checks +/// bound memory: /// -/// Shared guard used by every provider's non-streaming path. +/// 1. **Pre-read (`Content-Length`)** — when the header is present and +/// exceeds [`MAX_RESPONSE_BODY`], the body is rejected without reading a +/// single byte. A hostile provider advertising a huge body never allocates +/// it. +/// 2. **Streaming cap** — for responses without `Content-Length` (chunked +/// transfer), the body is read chunk by chunk and the read aborts the +/// moment the running total crosses [`MAX_RESPONSE_BODY`], so peak memory +/// never exceeds the cap by more than one chunk. +/// +/// Replaces the old `resp.bytes().await` + post-hoc length check, which +/// materialized the full body before the guard could fire. /// /// # Errors /// -/// Returns [`ApiError`] when `len` exceeds [`MAX_RESPONSE_BODY`]. +/// Returns [`ApiError`] when the body exceeds [`MAX_RESPONSE_BODY`] (either +/// via the header pre-check or the streaming cap), or on a transport error +/// reading the body. #[cfg(any(feature = "openai", feature = "anthropic", feature = "gemini"))] -pub(super) fn check_response_body(len: usize) -> Result<(), ApiError> { - if len > MAX_RESPONSE_BODY { +pub(super) async fn read_bounded_body(resp: reqwest::Response) -> Result { + if let Some(len) = resp.content_length() + && usize::try_from(len).map_or(true, |n| n > MAX_RESPONSE_BODY) + { return Err(ApiError::http(format!( - "response body too large: {len} bytes (max {MAX_RESPONSE_BODY})" + "response body too large: declared {len} bytes (max {MAX_RESPONSE_BODY})" ))); } - Ok(()) + let mut stream = resp.bytes_stream(); + let mut buf: Vec = Vec::new(); + while let Some(chunk) = stream.next().await { + let chunk = + chunk.map_err(|e| ApiError::http(format!("error reading response body: {e}")))?; + buf.extend_from_slice(&chunk); + if buf.len() > MAX_RESPONSE_BODY { + return Err(ApiError::http(format!( + "response body too large: streamed {} bytes (max {MAX_RESPONSE_BODY})", + buf.len() + ))); + } + } + Ok(buf.into()) } /// Shared HTTP-client configuration embedded by every provider builder. @@ -976,4 +1010,69 @@ mod tests { .with_pool_idle_timeout(Duration::from_secs(30)); assert!(config.build().is_ok()); } + + #[cfg(any(feature = "openai", feature = "anthropic", feature = "gemini"))] + async fn serve_once( + status: u16, + headers: String, + body: Vec, + ) -> (String, tokio::task::JoinHandle<()>) { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let handle = tokio::spawn(async move { + let (mut sock, _) = listener.accept().await.unwrap(); + let mut buf = [0u8; 1024]; + drop(sock.read(&mut buf).await); + let extra = if headers.is_empty() { + String::new() + } else { + format!("{headers}\r\n") + }; + let head = format!( + "HTTP/1.1 {status} OK\r\nContent-Length: {clen}\r\n{extra}\r\n", + clen = body.len(), + ); + drop(sock.write_all(head.as_bytes()).await); + drop(sock.write_all(&body).await); + drop(sock.flush().await); + }); + (format!("http://{addr}"), handle) + } + + #[cfg(any(feature = "openai", feature = "anthropic", feature = "gemini"))] + async fn get_response(url: &str) -> reqwest::Response { + reqwest::Client::new() + .get(url) + .send() + .await + .expect("request to test server must succeed") + } + + #[cfg(any(feature = "openai", feature = "anthropic", feature = "gemini"))] + #[tokio::test] + async fn read_bounded_body_accepts_under_limit() { + let body = b"{\"ok\":true}".to_vec(); + let (url, handle) = serve_once(200, String::new(), body.clone()).await; + let resp = get_response(&url).await; + let bytes = read_bounded_body(resp).await.expect("small body must pass"); + assert_eq!(bytes.as_ref(), body.as_slice()); + handle.await.unwrap(); + } + + #[cfg(any(feature = "openai", feature = "anthropic", feature = "gemini"))] + #[tokio::test] + async fn read_bounded_body_rejects_oversized_content_length() { + let body = vec![b'x'; MAX_RESPONSE_BODY + 1]; + let (url, handle) = serve_once(200, String::new(), body).await; + let resp = get_response(&url).await; + let err = read_bounded_body(resp) + .await + .expect_err("oversized body must reject"); + assert!( + err.to_string().contains("too large"), + "expected a too-large error, got: {err}" + ); + handle.await.unwrap(); + } } diff --git a/src/provider/anthropic.rs b/src/provider/anthropic.rs index 5ea8b49..ac42f99 100644 --- a/src/provider/anthropic.rs +++ b/src/provider/anthropic.rs @@ -290,11 +290,7 @@ impl ApiClient for AnthropicClient { let url = self.messages_url(); Box::pin(async move { let resp = Self::post_messages(&self.http, &url, &self.api_key, &body).await?; - let resp = resp - .bytes() - .await - .map_err(|e| ApiError::http(e.to_string()))?; - super::check_response_body(resp.len())?; + let resp = super::read_bounded_body(resp).await?; serde_json::from_slice::(&resp).map_err(|e| ApiError::http(e.to_string())) }) } @@ -1652,7 +1648,11 @@ mod tests { let StreamEvent::PartStart(ps) = text_start else { panic!("matched event must be a PartStart"); }; - assert!(ps.part.as_ref().is_some_and(crate::message::MessagePart::is_text)); + assert!( + ps.part + .as_ref() + .is_some_and(crate::message::MessagePart::is_text) + ); } #[test] diff --git a/src/provider/gemini.rs b/src/provider/gemini.rs index 738d73c..b00bd48 100644 --- a/src/provider/gemini.rs +++ b/src/provider/gemini.rs @@ -274,11 +274,7 @@ impl ApiClient for GeminiClient { Box::pin(async move { let resp = Self::post_content(&self.http, &url, &self.api_key, &body).await?; - let resp = resp - .bytes() - .await - .map_err(|e| ApiError::http(e.to_string()))?; - super::check_response_body(resp.len())?; + let resp = super::read_bounded_body(resp).await?; serde_json::from_slice::(&resp).map_err(|e| ApiError::http(e.to_string())) }) } @@ -340,11 +336,7 @@ impl ApiClient for GeminiClient { let url = self.generate_url(); Box::pin(async move { let resp = Self::post_content(&self.http, &url, &self.api_key, &body).await?; - let resp = resp - .bytes() - .await - .map_err(|e| ApiError::http(e.to_string()))?; - super::check_response_body(resp.len())?; + let resp = super::read_bounded_body(resp).await?; serde_json::from_slice::(&resp).map_err(|e| ApiError::http(e.to_string())) }) } diff --git a/src/provider/openai.rs b/src/provider/openai.rs index 58bb239..0c1f567 100644 --- a/src/provider/openai.rs +++ b/src/provider/openai.rs @@ -269,11 +269,7 @@ impl ApiClient for OpenAiClient { let resp = Self::post_completions(&self.http, &url, &self.api_key, &body.to_json(false)) .await?; - let resp = resp - .bytes() - .await - .map_err(|e| ApiError::http(e.to_string()))?; - super::check_response_body(resp.len())?; + let resp = super::read_bounded_body(resp).await?; serde_json::from_slice::(&resp).map_err(|e| ApiError::http(e.to_string())) }) } @@ -343,11 +339,7 @@ impl ApiClient for OpenAiClient { let resp = Self::post_completions(&self.http, &url, &self.api_key, &body.to_json(false)) .await?; - let resp = resp - .bytes() - .await - .map_err(|e| ApiError::http(e.to_string()))?; - super::check_response_body(resp.len())?; + let resp = super::read_bounded_body(resp).await?; serde_json::from_slice::(&resp).map_err(|e| ApiError::http(e.to_string())) }) } @@ -2006,19 +1998,6 @@ mod tests { assert_eq!(super::super::MAX_RESPONSE_BODY, 10 * 1024 * 1024); } - #[test] - fn body_size_check_rejects_oversized() { - // Verify the comparison logic used in create_message. - let oversized = super::super::MAX_RESPONSE_BODY + 1; - assert!(oversized > super::super::MAX_RESPONSE_BODY); - } - - #[test] - fn body_size_check_accepts_within_limit() { - let within = super::super::MAX_RESPONSE_BODY; - assert!(within <= super::super::MAX_RESPONSE_BODY); - } - #[test] fn request_body_response_format_emitted() { let msgs = vec![Message::user("hi")]; From ac80a1e3bbe08807f4018b7cf13d0c5e0acfd155 Mon Sep 17 00:00:00 2001 From: Iurii Bobrykov Date: Thu, 30 Jul 2026 15:04:47 +1200 Subject: [PATCH 08/20] fix: docs --- src/compact.rs | 10 +++++----- src/engine/bare.rs | 21 +++++++++++++-------- src/engine/core/lifecycle.rs | 26 +++----------------------- src/managers.rs | 4 ++-- src/reflection.rs | 7 +++++-- src/tool/registry.rs | 2 +- 6 files changed, 29 insertions(+), 41 deletions(-) diff --git a/src/compact.rs b/src/compact.rs index d48523f..83f4a60 100644 --- a/src/compact.rs +++ b/src/compact.rs @@ -177,7 +177,7 @@ pub trait ContextCompactor: Send + Sync { pub enum CompactBase { /// Target is a percentage of the full context window. /// - /// `target = context_window × compact_target_pct` + /// `target = context_window × compact_target_pct / 100` /// /// Use this when you want compaction to aim for a fixed fraction /// of the model's total capacity regardless of the trigger threshold. @@ -185,11 +185,11 @@ pub enum CompactBase { /// Target is a percentage of the trigger threshold. /// - /// `target = compact_threshold_tokens × compact_target_pct / 10_000` + /// `target = compact_threshold_tokens × compact_target_pct / 100` /// /// This is the default. With the default `threshold = 80` (80%) and /// `compact_target_pct = 70` (70%), compaction targets 56% of the - /// context window. + /// context window (`0.8 × 0.7 = 0.56`). #[default] Threshold, } @@ -418,8 +418,8 @@ impl ContextManager { /// Computed from [`compact_target`](Self::compact_target) and /// [`compact_target_pct`](Self::compact_target_pct): /// - /// - [`CompactBase::Threshold`]: `compact_threshold_tokens × pct` - /// - [`CompactBase::Context`]: `context_window × pct` + /// - [`CompactBase::Threshold`]: `compact_threshold_tokens × pct / 100` + /// - [`CompactBase::Context`]: `context_window × pct / 100` #[must_use] pub fn compact_target_tokens(&self) -> u64 { let base: u64 = match self.compact_base { diff --git a/src/engine/bare.rs b/src/engine/bare.rs index 9f5f0ac..82da8d4 100644 --- a/src/engine/bare.rs +++ b/src/engine/bare.rs @@ -349,14 +349,19 @@ impl BareLoop { } } - /// Get the conversation history. - /// - /// Returns a slice of [`Message`] representing the full conversation - /// so far: the opening user message, contributor injections, assistant - /// responses, and tool-result messages. The history is owned by the - /// driving state machine; it is empty until the first - /// [`run()`](crate::engine::core::Loop::run) call mints a machine for - /// the run. + /// Get the conversation as the driving state machine currently holds it. + /// + /// Returns the machine's + /// [`full_history`](crate::engine::core::LoopMachine::full_history): + /// committed history plus the current run's pending messages, merged into + /// one [`Vec`]. This is the complete view the next model call would see. + /// + /// After a compaction pass the committed history is the compacted slice, + /// not the original messages — a compactor is free to summarize or drop + /// entries, so the opening user message and early turns may no longer be + /// present verbatim. Contributor messages are transient by design and are + /// never persisted into history. Empty until the first + /// [`run()`](crate::engine::core::Loop::run) call mints a machine. pub fn conversation(&self) -> Vec { self.machine.full_history() } diff --git a/src/engine/core/lifecycle.rs b/src/engine/core/lifecycle.rs index 783ba09..627214f 100644 --- a/src/engine/core/lifecycle.rs +++ b/src/engine/core/lifecycle.rs @@ -253,23 +253,6 @@ impl ToolCall { } } -/// The result of one `run(prompt)` call. -/// -/// One prompt → tool loop → final answer. Fresh per call: `id` and the -/// accounting fields rotate with each run, while the owning -/// [`Session`] keeps its identity stable. -/// -/// `Run` is a type alias for [`Run`], so the two are interchangeable in -/// ``` -/// use loopctl::engine::core::Run; -/// -/// fn summarize(result: &Run) -> String { -/// format!("{} turns", result.turn_count()) -/// } -/// -/// let run: Run = Run::new("hello", &Default::default()); -/// assert_eq!(summarize(&run), "0 turns"); -/// ``` /// One iteration of the agent loop — a single LLM call and any tools it /// triggered. /// @@ -604,6 +587,9 @@ impl Session { } } +/// The result of a `run()` call — either the completed [`Run`] or a [`LoopError`]. +pub type RunResult = Result; + /// The core agent lifecycle trait. /// /// Implement this trait to create a new type of agent. The framework @@ -643,12 +629,6 @@ impl Session { /// fn cancel(&self) {} /// } /// ``` -/// The result of a `run()` call — either the completed [`Run`] or a [`LoopError`]. -pub type RunResult = Result; - -/// The core agent lifecycle trait. -/// -/// Implement this trait to create a new type of agent. pub trait Loop: Send + Sync { /// Drive one run of the agent loop for the given user prompt. /// diff --git a/src/managers.rs b/src/managers.rs index 6537caf..8e5c769 100644 --- a/src/managers.rs +++ b/src/managers.rs @@ -54,9 +54,9 @@ //! let managers = LoopManagers::new() //! .with_fallback(FallbackManager::for_model("llm-70b")) //! .with_detection(DetectionManager::default()) -//! .with_observer(Arc::new(logging_observer)) +//! .with_observer(Arc::new(logging_observer)); //! -//! let agent = BareLoop::new_with_managers(client, tools, runtime, config); +//! let agent = BareLoop::new_with_managers(client, tools, config, managers); //! ``` //! //! Every capability is optional. A bundle with no `.with_*()` calls still diff --git a/src/reflection.rs b/src/reflection.rs index a442bc7..5ee57df 100644 --- a/src/reflection.rs +++ b/src/reflection.rs @@ -30,7 +30,7 @@ //! let strategy = ExponentialBackoffRecovery::new(3); //! //! // Usage (in BareLoop): -//! // let analysis = reflector.analyze(error, tool_name, input, &context).await?; +//! // let analysis = reflector.analyze(error, tool_name, input, None, &context).await?; //! // let action = strategy.decide(&analysis, attempt, max_attempts).await; //! ``` @@ -868,7 +868,10 @@ pub trait RecoveryStrategy: Send + Sync { /// max_attempts: 3, /// }; /// -/// let analysis = reflector.analyze("error", "tool", &json!({}), &context).await.unwrap(); +/// let analysis = reflector +/// .analyze("error", "tool", &json!({}), None, &context) +/// .await +/// .unwrap(); /// assert!(!analysis.is_recoverable); /// # }); /// ``` diff --git a/src/tool/registry.rs b/src/tool/registry.rs index a9b877e..9868c9d 100644 --- a/src/tool/registry.rs +++ b/src/tool/registry.rs @@ -143,7 +143,7 @@ impl ToolRegistry { /// ```rust,ignore /// let schemas = registry.all_schemas(); /// for schema in &schemas { - /// println!(" - {}: {}", schema.name, schema.description); + /// println!(" - {}: {}", schema.tool, schema.description); /// } /// ``` #[must_use] From 91046f7fa773bdf55358c32ae38430eb33f9be30 Mon Sep 17 00:00:00 2001 From: Iurii Bobrykov Date: Fri, 31 Jul 2026 12:07:40 +1200 Subject: [PATCH 09/20] feat: wire up loop memory --- CHANGELOG.md | 13 ++ src/capabilities.rs | 23 ++++ src/engine/bare.rs | 247 +++++++++++++++++++++++++++++++++++- src/engine/bare/dispatch.rs | 28 ++++ src/managers.rs | 52 ++++++++ src/memory.rs | 106 ++++++++++------ src/memory/builtin.rs | 64 +++++++--- src/stream/handler.rs | 1 + src/tool/health.rs | 21 +-- 9 files changed, 484 insertions(+), 71 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 052189f..3f11c03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -224,6 +224,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/2.0.0. shift-based mix) so the same attempt always yields the same delay while successive attempts spread their backoffs — no randomness dependency. `base_delay` (the raw exponential core) remains public. +- **`LoopMemory` is now wired into the framework.** The trait was previously + exported and documented but consumed by nothing. The engine now: + - **Stores** a trajectory entry after each successful tool call (tool name, + input, result) via `LoopManagers::memory()`. + - **Retrieves** up to 3 relevant entries before each turn and injects them + as a system message into the conversation. + - **Consolidates** (prunes) the store at the end of a successful run. + The `LoopMemory` trait is now object-safe (`Pin>` returns) + so the store can live behind `Arc` on + `LoopManagers`. Configure via `BareLoop::set_memory` / + `with_memory`, or `LoopManagers::set_memory` / `with_memory`. + A `RememberCapable` capability trait exposes it to trait-bounded code. + All three hooks are no-ops when no memory store is attached. ### Changed diff --git a/src/capabilities.rs b/src/capabilities.rs index 452a0c6..daef93d 100644 --- a/src/capabilities.rs +++ b/src/capabilities.rs @@ -162,6 +162,29 @@ pub trait Compactable { fn context_manager(&self) -> Option<&Arc>; } +/// Capability to store, retrieve, and consolidate agent memory. +/// +/// When a [`LoopMemory`](crate::memory::LoopMemory) backend is +/// configured, the engine stores tool-execution trajectories, retrieves +/// relevant entries as context before each turn, and consolidates the store +/// at the end of a successful run. +/// +/// # Implementors +/// +/// - [`LoopManagers`](crate::managers::LoopManagers) — the framework's default implementation. +/// +/// # When to use +/// +/// Use this trait bound when you need to access the memory backend during +/// the agent loop — for example to inspect what was stored or trigger a +/// manual consolidation. +pub trait RememberCapable { + /// Returns the memory backend, if configured. + /// + /// Returns `None` when no memory store is attached. + fn memory(&self) -> Option<&Arc>; +} + /// Capability to stream LLM responses with retry, timeout, and fallback. /// /// When a [`StreamHandler`] is configured, the loop delegates streaming diff --git a/src/engine/bare.rs b/src/engine/bare.rs index 82da8d4..685068d 100644 --- a/src/engine/bare.rs +++ b/src/engine/bare.rs @@ -725,6 +725,31 @@ impl BareLoop { self.managers.set_health_registry(registry); } + /// Set the agent memory backend. + /// + /// When set, the engine stores a trajectory entry after each successful + /// tool call, retrieves relevant entries as context before each turn, + /// and consolidates the store at the end of a successful run. Must be + /// called before [`run()`](crate::engine::core::Loop::run). + /// + /// # Panics (debug only) + /// + /// In debug builds, panics if called after the session has started. + /// + /// # Example + /// + /// ```rust,ignore + /// use loopctl::memory::InMemoryStore; + /// use std::sync::Arc; + /// + /// let mut agent = BareLoop::new(client, registry, config); + /// agent.set_memory(Arc::new(InMemoryStore::new())); + /// ``` + pub fn set_memory(&mut self, memory: Arc) { + self.debug_assert_idle(); + self.managers.set_memory(memory); + } + /// Set the middleware pipeline for tool dispatch. /// /// Replaces the default (no pipeline) with a caller-supplied @@ -939,6 +964,14 @@ impl BareLoop { self } + /// Set the agent memory backend, consuming `self`. Fluent mirror of + /// [`set_memory`](BareLoop::set_memory). + #[must_use] + pub fn with_memory(mut self, memory: Arc) -> Self { + self.set_memory(memory); + self + } + /// Set the middleware pipeline, consuming `self`. Fluent mirror of /// [`set_pipeline`](BareLoop::set_pipeline). /// @@ -1497,7 +1530,29 @@ impl BareLoop { self.notify_turn_start(current_turn, &turn_input); - let contributor_messages = self.collect_contributor_messages(current_turn); + let mut contributor_messages = self.collect_contributor_messages(current_turn); + + if let Some(memory) = self.managers.memory() { + match memory.retrieve(&turn_input, 3).await { + Ok(entries) if !entries.is_empty() => { + let summary = entries + .iter() + .map(|e| e.memory.as_str()) + .collect::>() + .join("\n"); + contributor_messages.push(Message::new( + crate::message::Role::System, + vec![crate::message::MessagePart::text(format!( + "Relevant memory:\n{summary}" + ))], + )); + } + Err(e) => { + tracing::warn!(error = %e, "memory retrieve failed"); + } + Ok(_) => {} + } + } let cancel = Arc::clone(&self.cancelled); tokio::select! { @@ -1796,6 +1851,11 @@ impl crate::engine::core::Loop for BareLoop { if error.is_none() { self.machine.commit_pending(); + if let Some(memory) = self.managers.memory() + && let Err(e) = memory.consolidate().await + { + tracing::warn!(error = %e, "memory consolidate failed"); + } } else { self.machine.discard_pending(); } @@ -2358,6 +2418,191 @@ mod tests { assert_eq!(result.tool_call_count(), 1); } + #[tokio::test] + async fn memory_stores_trajectory_after_tool_call() { + use crate::memory::{InMemoryStore, LoopMemory}; + + let client = MockClient::new("test-model"); + client.add_tool_then_text( + "tool_1", + "echo", + json!({"message": "hello"}), + "I echoed your message.", + ); + + let mut registry = ToolRegistry::new(); + registry.register(EchoTool); + + let memory = Arc::new(InMemoryStore::new()); + let mut agent = BareLoop::new(Arc::new(client), registry, make_config()); + agent.set_memory(memory.clone()); + + let result = agent + .run("Echo hello", &RunConfig::default()) + .await + .unwrap(); + assert_eq!(result.tool_call_count(), 1); + + assert_eq!( + memory.len(), + 1, + "a successful tool call must store one trajectory entry" + ); + let entries = memory.retrieve("echo", 5).await.unwrap(); + assert!( + entries.iter().any(|e| e.memory.contains("tool=echo")), + "stored entry must carry the tool name" + ); + } + + struct RequestCapturingClient { + model: String, + responses: Arc>>>, + captured: Arc>>, + } + + impl RequestCapturingClient { + fn new(model: &str, captured: Arc>>) -> Self { + Self { + model: model.to_string(), + responses: Arc::new(Mutex::new(Vec::new())), + captured, + } + } + + fn add_text_response(&self, text: &str) { + crate::error::recover_guard(self.responses.lock()).push(vec![ + StreamEvent::MessageStart(MessageStart { + message: MessageMetadata { + id: "msg_test".into(), + role: "assistant".into(), + model: self.model.clone(), + }, + }), + StreamEvent::PartStart(PartStart { + index: 0, + part: Some(MessagePart::text(text)), + }), + StreamEvent::IndexedDelta(IndexedDelta { + index: 0, + delta: DeltaPart::Text { + text: text.to_string(), + }, + }), + StreamEvent::PartStop, + StreamEvent::MessageDelta(MessageDelta { + delta: MessageDeltaPayload { + stop_reason: Some("end_turn".to_string()), + }, + usage: None, + }), + StreamEvent::MessageStop, + ]); + } + } + + impl ApiClient for RequestCapturingClient { + fn model(&self) -> String { + self.model.clone() + } + fn stream_messages( + &self, + request: &crate::api::StreamRequest, + ) -> Pin> + Send + 'static>> + { + let texts: Vec = request + .messages + .iter() + .flat_map(|m| { + m.parts + .iter() + .filter_map(|p| p.as_text().map(std::string::ToString::to_string)) + }) + .collect(); + crate::error::recover_guard(self.captured.lock()).extend(texts); + let mut guard = crate::error::recover_guard(self.responses.lock()); + if let Some(events) = guard.pop_front() { + let events: Vec> = + events.into_iter().map(Ok).collect(); + Box::pin(futures::stream::iter(events)) + } else { + Box::pin(futures::stream::iter(vec![Err(ApiError::api( + "No more mock responses", + ))])) + } + } + fn create_message( + &self, + _request: &crate::api::StreamRequest, + ) -> Pin> + Send + '_>> { + Box::pin(async { Ok(json!({"content": []})) }) + } + } + + #[tokio::test] + async fn memory_retrieve_injects_into_request() { + use crate::memory::{InMemoryStore, LoopMemory, MemoryCategory, MemoryEntry}; + + let memory = Arc::new(InMemoryStore::new()); + memory + .store(MemoryEntry::new(MemoryCategory::Fact, "the answer is 42")) + .await + .unwrap(); + + let captured: Arc>> = Arc::new(Mutex::new(Vec::new())); + let captured_clone = Arc::clone(&captured); + let client = RequestCapturingClient::new("test", captured_clone); + client.add_text_response("done"); + + let mut agent = BareLoop::new(Arc::new(client), ToolRegistry::new(), make_config()); + agent.set_memory(memory); + + agent.run("answer", &RunConfig::default()).await.unwrap(); + + let msgs = crate::error::recover_guard(captured.lock()).clone(); + let combined = msgs.join(" "); + assert!( + combined.contains("Relevant memory"), + "request must contain the injected memory message: {combined}" + ); + assert!( + combined.contains("the answer is 42"), + "request must contain the stored entry text: {combined}" + ); + } + + #[tokio::test] + async fn memory_consolidate_prunes_on_successful_run() { + use crate::memory::{InMemoryStore, LoopMemory, MemoryEntry}; + + let memory = Arc::new(InMemoryStore::new()); + let mut stale = MemoryEntry::new(crate::memory::MemoryCategory::Fact, "stale entry"); + stale.relevance = 0.01; + memory.store(stale).await.unwrap(); + memory + .store(MemoryEntry::new( + crate::memory::MemoryCategory::Fact, + "important entry", + )) + .await + .unwrap(); + assert_eq!(memory.len(), 2, "precondition: two entries"); + + let client = MockClient::new("test"); + client.add_text_response("done"); + + let mut agent = BareLoop::new(Arc::new(client), ToolRegistry::new(), make_config()); + agent.set_memory(memory.clone()); + + agent.run("go", &RunConfig::default()).await.unwrap(); + + assert_eq!( + memory.len(), + 1, + "consolidate must prune the low-relevance entry on successful run" + ); + } + struct SequenceObserver { log: Arc>>, } diff --git a/src/engine/bare/dispatch.rs b/src/engine/bare/dispatch.rs index ecac203..5c06b07 100644 --- a/src/engine/bare/dispatch.rs +++ b/src/engine/bare/dispatch.rs @@ -392,6 +392,7 @@ impl BareLoop { self.notify_tool_post(turn_idx, &tc, &tool_result); self.notify_post_tool_use_hooks(&tc, &tool_result, turn_idx); self.record_tool_health(tc.tool.as_str(), &tool_result); + self.record_tool_memory(&tc, &tool_result).await; if !tool_result.is_error { return Ok(tool_result); @@ -782,6 +783,33 @@ impl BareLoop { #[cfg(not(feature = "tool_health"))] fn record_tool_health(&self, _tool_name: &str, _tool_result: &ToolDispatchResult) {} + /// Store a successful tool-execution trajectory into the memory backend. + /// + /// Called after each tool dispatch that did not error. Guards on + /// [`RememberCapable`] — when no memory store is configured this is a + /// no-op. Builds a [`MemoryEntry`](crate::memory::MemoryEntry) tagged + /// [`Trajectory`](crate::memory::MemoryCategory::Trajectory) carrying the + /// tool name, input, and result, then stores it. Errors are logged and + /// swallowed — a memory-store failure must never crash the turn. + async fn record_tool_memory(&self, tc: &ToolCall, tool_result: &ToolDispatchResult) { + let Some(memory) = self.managers.memory() else { + return; + }; + if tool_result.is_error { + return; + } + let entry = crate::memory::MemoryEntry::new( + crate::memory::MemoryCategory::Trajectory, + format!( + "tool={}; input={}; result={}", + tc.tool, tc.input, tool_result.output + ), + ); + if let Err(e) = memory.store(entry).await { + tracing::warn!(error = %e, tool = %tc.tool, "memory store failed"); + } + } + /// Dispatch a tool call through the middleware pipeline. /// /// Builds a [`ToolDispatchContext`] and delegates to the pipeline's diff --git a/src/managers.rs b/src/managers.rs index 8e5c769..923395f 100644 --- a/src/managers.rs +++ b/src/managers.rs @@ -178,6 +178,15 @@ pub struct LoopManagers { /// breaker opened, blocking subsequent calls until recovery. #[cfg(feature = "tool_health")] health_registry: Option>, + + /// Optional agent memory backend. + /// + /// When set, the engine stores a trajectory entry after each successful + /// tool call, retrieves relevant entries before each turn to inject as + /// context, and consolidates (prunes) the store at the end of a + /// successful run. Persists across manager resets — memory is meant to + /// survive a `reset_all`. + memory: Option>, } impl LoopManagers { @@ -204,6 +213,7 @@ impl LoopManagers { hook_executor: None, #[cfg(feature = "tool_health")] health_registry: None, + memory: None, } } @@ -397,6 +407,42 @@ impl LoopManagers { self.health_registry = Some(registry); } + /// Set the agent memory backend (builder-style). + /// + /// When set, the engine stores tool-execution trajectories, retrieves + /// relevant entries as context before each turn, and consolidates the + /// store at the end of a successful run. + /// + /// # Example + /// + /// ```rust,ignore + /// use loopctl::memory::InMemoryStore; + /// use std::sync::Arc; + /// + /// let managers = LoopManagers::new() + /// .with_memory(Arc::new(InMemoryStore::new())); + /// ``` + #[must_use] + pub fn with_memory(mut self, memory: Arc) -> Self { + self.memory = Some(memory); + self + } + + /// Set the agent memory backend. + /// + /// Non-consuming variant of [`with_memory`](Self::with_memory). + pub fn set_memory(&mut self, memory: Arc) { + self.memory = Some(memory); + } + + /// Borrow the memory backend, if configured. + /// + /// Returns `None` when no memory store is attached. + #[must_use] + pub fn memory(&self) -> Option<&Arc> { + self.memory.as_ref() + } + /// Reset all managers and observers to their initial state. /// /// Clears the fallback circuit breaker, loop/convergence detection @@ -508,6 +554,12 @@ impl crate::capabilities::Compactable for LoopManagers { } } +impl crate::capabilities::RememberCapable for LoopManagers { + fn memory(&self) -> Option<&Arc> { + self.memory.as_ref() + } +} + impl crate::capabilities::StreamCapable for LoopManagers { fn stream_handler(&self) -> &StreamHandler { self.stream_handler diff --git a/src/memory.rs b/src/memory.rs index c1c0fd1..798696d 100644 --- a/src/memory.rs +++ b/src/memory.rs @@ -58,29 +58,50 @@ use crate::error::LoopError; use std::future::Future; +use std::pin::Pin; pub use builtin::InMemoryStore; pub use entry::{ConsolidationStats, MemoryCategory, MemoryEntry}; + pub mod builtin; pub mod entry; -/// A memory system for loops. +/// A memory system for agent loops. +/// +/// The trait the engine uses to persist, recall, and consolidate knowledge +/// across turns and runs. When a memory store is attached via +/// [`BareLoop::set_memory`], the engine: +/// +/// - **Stores** a [`MemoryEntry`] after every successful tool call, +/// recording what tool ran, with what input, and what it returned. +/// - **Retrieves** up to a few relevant entries before each turn and +/// injects them as a system message so the model sees prior experience. +/// - **Consolidates** the store at the end of each successful run, +/// pruning low-relevance entries. +/// +/// All three hooks are no-ops when no store is attached, so memory is +/// purely opt-in. /// -/// Implementations can store and retrieve entries using different -/// strategies (vector similarity, keyword matching, recency, etc.). +/// # Object safety /// -/// # Implementing +/// Async methods return [`Pin>`] so the trait is object-safe +/// and a store can be held behind `Arc` on +/// [`LoopManagers`] — the same convention used by [`Reflector`] and +/// [`RecoveryStrategy`]. Implementations wrap each method body in +/// `Box::pin(async move { ... })`; see [`InMemoryStore`] for a reference. /// -/// At a minimum you must provide [`store`](LoopMemory::store), -/// [`retrieve`](LoopMemory::retrieve), [`consolidate`](LoopMemory::consolidate), -/// and [`len`](LoopMemory::len). The trait supplies a default -/// [`is_empty`](LoopMemory::is_empty) implementation that delegates to `len`. +/// [`BareLoop::set_memory`]: crate::engine::BareLoop::set_memory +/// [`LoopManagers`]: crate::managers::LoopManagers +/// [`Reflector`]: crate::reflection::Reflector +/// [`RecoveryStrategy`]: crate::reflection::RecoveryStrategy /// /// # Example /// /// ```rust /// use loopctl::memory::{LoopMemory, MemoryEntry, MemoryCategory, ConsolidationStats}; /// use loopctl::error::LoopError; +/// use std::future::Future; +/// use std::pin::Pin; /// use std::sync::RwLock; /// /// struct MyStore { @@ -89,30 +110,30 @@ pub mod entry; /// /// impl LoopMemory for MyStore { /// fn store(&self, entry: MemoryEntry) -/// -> impl Future> + Send +/// -> Pin> + Send + '_>> /// { -/// async move { +/// Box::pin(async move { /// self.entries.write().unwrap().push(entry); /// Ok(()) -/// } +/// }) /// } /// fn retrieve(&self, query: &str, limit: usize) -/// -> impl Future, LoopError>> + Send +/// -> Pin, LoopError>> + Send + '_>> /// { /// let query = query.to_string(); -/// async move { +/// Box::pin(async move { /// let entries = self.entries.read().unwrap(); /// Ok(entries.iter() /// .filter(|e| e.memory.contains(&query)) /// .take(limit) /// .cloned() /// .collect()) -/// } +/// }) /// } /// fn consolidate(&self) -/// -> impl Future> + Send +/// -> Pin> + Send + '_>> /// { -/// async move { +/// Box::pin(async move { /// let mut entries = self.entries.write().unwrap(); /// let before = entries.len(); /// entries.retain(|e| e.relevance > 0.1); @@ -123,7 +144,7 @@ pub mod entry; /// pruned: before - after, /// ..Default::default() /// }) -/// } +/// }) /// } /// fn len(&self) -> usize { /// self.entries.read().unwrap().len() @@ -133,19 +154,22 @@ pub mod entry; pub trait LoopMemory: Send + Sync { /// Store a new memory entry. /// - /// Called whenever the agent encounters information worth remembering — - /// for example after a successful tool invocation, a resolved error, or - /// an insight drawn from conversation. Implementations should persist the - /// entry in whatever backing store they use. + /// Called by the engine after every successful tool call to record the + /// trajectory — the tool name, its input, and its result. Implementations + /// should persist the entry in whatever backing store they use. /// - /// Takes `&self` so that memory stores can be shared via `Arc`. - /// Implementations that need interior mutability (e.g. an in-memory `Vec`) - /// should use `Mutex`, `RwLock`, or lock-free structures internally. - fn store(&self, entry: MemoryEntry) -> impl Future> + Send; + /// Takes `&self` so that memory stores can be shared via + /// `Arc`. Implementations that need interior mutability + /// (e.g. an in-memory `Vec`) should use `Mutex`, `RwLock`, or lock-free + /// structures internally. + fn store( + &self, + entry: MemoryEntry, + ) -> Pin> + Send + '_>>; /// Retrieve memory entries relevant to the given query. /// - /// Called before each turn (or on demand) to surface context the agent + /// Called by the engine before each turn to surface context the agent /// can use. Returns up to `limit` entries ordered by relevance. The /// definition of "relevance" is left to the implementation — common /// strategies include vector embedding similarity, keyword overlap, @@ -154,26 +178,30 @@ pub trait LoopMemory: Send + Sync { /// Implementations that track [`MemoryEntry::access_count`] must use /// interior mutability (e.g. `AtomicUsize`, `Mutex`) since this method /// takes `&self`. - fn retrieve( - &self, - query: &str, + fn retrieve<'a>( + &'a self, + query: &'a str, limit: usize, - ) -> impl Future, LoopError>> + Send; + ) -> Pin, LoopError>> + Send + 'a>>; - /// Consolidate memory (e.g. prune, summarize, compress). + /// Consolidate memory (prune, summarize, compress). /// - /// Called periodically to keep the memory store healthy. Implementations - /// may remove low-relevance entries, merge duplicates, or produce - /// compressed summaries. Returns [`ConsolidationStats`] describing what - /// was done. + /// Called by the engine at the end of each successful run to keep the + /// store healthy. Implementations may remove low-relevance entries, + /// merge duplicates, or produce compressed summaries. Returns + /// [`ConsolidationStats`] describing what was done. /// - /// Takes `&self` so that memory stores can be shared via `Arc`. - /// Implementations should use interior mutability as needed. - fn consolidate(&self) -> impl Future> + Send; + /// Takes `&self` so that memory stores can be shared via + /// `Arc`. Implementations should use interior mutability + /// as needed. + fn consolidate( + &self, + ) -> Pin> + Send + '_>>; /// Number of entries currently stored. /// - /// Used by the framework and by [`is_empty`](LoopMemory::is_empty). + /// Used by [`is_empty`](Self::is_empty) and reported by the engine's + /// consolidate hook after each run. fn len(&self) -> usize; /// Whether the memory is empty. diff --git a/src/memory/builtin.rs b/src/memory/builtin.rs index ea91754..f6f29f1 100644 --- a/src/memory/builtin.rs +++ b/src/memory/builtin.rs @@ -40,6 +40,7 @@ use crate::error::LoopError; use crate::memory::{ConsolidationStats, LoopMemory, MemoryEntry}; use std::future::Future; +use std::pin::Pin; use std::sync::RwLock; /// A simple in-memory store for loop memory entries. @@ -173,27 +174,28 @@ impl Default for InMemoryStore { } } -#[allow(clippy::manual_async_fn)] impl LoopMemory for InMemoryStore { /// Store a new memory entry by appending it to the backing list. /// - /// Called whenever the agent encounters information worth remembering — - /// for example after a successful tool invocation, a resolved error, or - /// an insight drawn from conversation. + /// Called by the engine after every successful tool call to record the + /// trajectory — what tool ran, with what input, and what it returned. /// /// # Errors /// /// This implementation never returns an error. - fn store(&self, entry: MemoryEntry) -> impl Future> + Send { - async move { + fn store( + &self, + entry: MemoryEntry, + ) -> Pin> + Send + '_>> { + Box::pin(async move { crate::error::recover_guard(self.entries.write()).push(entry); Ok(()) - } + }) } /// Retrieve memory entries relevant to the given query. /// - /// Called before each turn (or on demand) to surface context the agent + /// Called by the engine before each turn to surface context the agent /// can use. Returns up to `limit` entries ordered by a composite score /// that blends: /// @@ -226,13 +228,13 @@ impl LoopMemory for InMemoryStore { /// } /// # }); /// ``` - fn retrieve( - &self, - query: &str, + fn retrieve<'a>( + &'a self, + query: &'a str, limit: usize, - ) -> impl Future, LoopError>> + Send { + ) -> Pin, LoopError>> + Send + 'a>> { let query = query.to_string(); - async move { + Box::pin(async move { let query_lower = query.to_lowercase(); let query_words: Vec<&str> = query_lower.split_whitespace().collect(); @@ -269,13 +271,13 @@ impl LoopMemory for InMemoryStore { scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal)); Ok(scored.into_iter().take(limit).map(|(_, e)| e).collect()) - } + }) } /// Consolidate memory by pruning low-relevance entries. /// - /// Called periodically by the framework to keep the memory store healthy. - /// This implementation removes entries whose + /// Called by the engine at the end of each successful run to keep the + /// memory store healthy. This implementation removes entries whose /// [`relevance`](MemoryEntry::relevance) score has decayed below 0.05. /// It does **not** perform merging — [`merged`](ConsolidationStats::merged) /// and [`bytes_saved`](ConsolidationStats::bytes_saved) are always zero. @@ -297,8 +299,10 @@ impl LoopMemory for InMemoryStore { /// println!("Pruned {} entries", stats.pruned); /// # }); /// ``` - fn consolidate(&self) -> impl Future> + Send { - async move { + fn consolidate( + &self, + ) -> Pin> + Send + '_>> { + Box::pin(async move { let mut entries = crate::error::recover_guard(self.entries.write()); let entries_before = entries.len(); entries.retain(|e| e.relevance >= 0.05); @@ -310,13 +314,13 @@ impl LoopMemory for InMemoryStore { merged: 0, bytes_saved: 0, }) - } + }) } /// Number of entries currently stored. /// - /// Used by the framework to monitor memory usage and by the - /// [`is_empty`](LoopMemory::is_empty) provided method. + /// Used by [`is_empty`](LoopMemory::is_empty) and reported by the + /// engine's consolidate hook after each run. fn len(&self) -> usize { crate::error::recover_guard(self.entries.read()).len() } @@ -326,6 +330,7 @@ impl LoopMemory for InMemoryStore { mod tests { use super::*; use crate::memory::MemoryCategory; + use std::sync::Arc; #[tokio::test] async fn test_store_and_retrieve() { @@ -502,4 +507,21 @@ mod tests { assert!((results[1].relevance - 0.5).abs() < 1e-6); assert!((results[2].relevance - 0.1).abs() < 1e-6); } + + #[tokio::test] + async fn loop_memory_round_trips() { + use crate::memory::{LoopMemory, MemoryCategory, MemoryEntry}; + let store: Arc = Arc::new(InMemoryStore::new()); + store + .store(MemoryEntry::new(MemoryCategory::Trajectory, "tool result")) + .await + .unwrap(); + assert_eq!(store.len(), 1); + let results = store.retrieve("tool", 5).await.unwrap(); + assert_eq!(results.len(), 1); + assert!(results[0].memory.contains("tool result")); + let stats = store.consolidate().await.unwrap(); + assert_eq!(stats.entries_after, 1); + assert!(!store.is_empty()); + } } diff --git a/src/stream/handler.rs b/src/stream/handler.rs index 5a3c0e2..9e24d66 100644 --- a/src/stream/handler.rs +++ b/src/stream/handler.rs @@ -1725,6 +1725,7 @@ impl StreamHandler { return; } let delay = self.retry_config.jittered_base_delay(transport_attempts); + let delay = clamp_delay_to_deadline(delay, total_deadline); transport_attempts = transport_attempts.saturating_add(1); sleep_cancellable(delay, cancel).await?; continue 'outer; diff --git a/src/tool/health.rs b/src/tool/health.rs index f177a61..876e5fe 100644 --- a/src/tool/health.rs +++ b/src/tool/health.rs @@ -611,23 +611,24 @@ impl ToolCircuitBreaker { } } - /// Whether the breaker would treat the next call as a `HalfOpen` probe. - /// - /// Pure read: `true` when the breaker is already `HalfOpen`, or when it - /// is `Open` but the recovery duration has elapsed (so the next - /// [`allow_request`](Self::allow_request) would transition to - /// `HalfOpen`). Lets an availability check report "a recovery probe is - /// pending" without performing the transition. Complements - /// [`would_allow_request`](Self::would_allow_request). + /// Whether the next [`allow_request`](Self::allow_request) call would + /// transition an `Open` breaker into `HalfOpen`. + /// + /// Pure read: `true` only when the breaker is `Open` and the recovery + /// duration has elapsed — i.e. the next `allow_request` would perform the + /// `Open`→`HalfOpen` transition and grant the probe slot. Returns `false` + /// for `HalfOpen` (a probe is already in flight; the next + /// `allow_request` refuses to avoid a thundering herd) and for `Closed` + /// (requests are allowed unconditionally, no transition pending). + /// Complements [`would_allow_request`](Self::would_allow_request). #[must_use] pub fn would_be_half_open(&self) -> bool { let state = crate::error::recover_guard(self.state.lock()); match state.circuit { - CircuitState::HalfOpen => true, CircuitState::Open => state .last_failure_time .is_some_and(|t| t.elapsed() >= self.recovery_duration), - CircuitState::Closed => false, + CircuitState::HalfOpen | CircuitState::Closed => false, } } From 42b39a5d2af059116e857a592b375439cc9d9620 Mon Sep 17 00:00:00 2001 From: Iurii Bobrykov Date: Fri, 31 Jul 2026 12:28:54 +1200 Subject: [PATCH 10/20] chore: use fastrand in jitter --- Cargo.toml | 1 + src/stream/handler.rs | 58 +++++++++++++++++++++---------------------- 2 files changed, 29 insertions(+), 30 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 599957f..281d74e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ path = "src/lib.rs" [dependencies] futures = "0.3" +fastrand = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" serde_repr = "0.1" diff --git a/src/stream/handler.rs b/src/stream/handler.rs index 9e24d66..a26c29a 100644 --- a/src/stream/handler.rs +++ b/src/stream/handler.rs @@ -258,14 +258,13 @@ impl StreamRetryConfig { /// The raw exponential backoff with [`jitter_factor`](Self::jitter_factor) applied. /// - /// Returns [`base_delay`](Self::base_delay)`(attempt)` scaled by a - /// deterministic factor in `[1 - jitter_factor, 1 + jitter_factor]`. The - /// factor is derived from the attempt number via a shift-based mix, so the - /// same attempt always yields the same delay (reproducible in tests) while - /// successive attempts still spread their backoffs — avoiding a - /// thundering herd where every retry lands on the same tick. When - /// [`jitter_factor`](Self::jitter_factor) is `0.0`, returns - /// [`base_delay`](Self::base_delay) unchanged. + /// Returns [`base_delay`](Self::base_delay)`(attempt)` scaled by a random + /// factor in `[1 - jitter_factor, 1 + jitter_factor]`, drawn from + /// [`fastrand`]. Concurrent retries with the same attempt number get + /// different delays — avoiding a thundering herd where every client + /// retries on the same tick. When [`jitter_factor`](Self::jitter_factor) + /// is `0.0`, returns [`base_delay`](Self::base_delay) unchanged (no + /// randomness, no allocation). /// /// This is the delay [`StreamHandler`] sleeps between transport-retry /// attempts; [`base_delay`](Self::base_delay) is the deterministic core @@ -287,24 +286,18 @@ impl StreamRetryConfig { if self.jitter_factor == 0.0 { return base; } - let f = Self::jitter_fraction(attempt) * self.jitter_factor; + let f = Self::random_signed_fraction() * self.jitter_factor; base.mul_f64(1.0 + f) } - /// A deterministic signed fraction in `[-1.0, 1.0)` derived from `attempt`. + /// A random signed fraction in `[-1.0, 1.0)` from [`fastrand`]. /// - /// Shifts and mixes the attempt bits so successive attempts map to - /// well-spread fractions; the same attempt always yields the same value. - /// Used by [`jittered_base_delay`](Self::jittered_base_delay) to scale - /// the backoff without pulling in a randomness dependency. + /// Draws a uniform `f64` in `[0.0, 1.0)` from fastrand's thread-local + /// Wyrand PRNG and remaps it to `[-1.0, 1.0)`. Each call produces a + /// different result, so concurrent retries spread their backoffs. #[must_use] - fn jitter_fraction(attempt: u32) -> f64 { - let mixed = attempt - .wrapping_mul(2_654_435_761) - .rotate_left(13) - .wrapping_add(0x9E37_79B9); - let scaled = f64::from(mixed >> 8) / f64::from(1u32 << 24); - (scaled - 0.5) * 2.0 + fn random_signed_fraction() -> f64 { + (fastrand::f64() - 0.5) * 2.0 } /// Validates the configuration, returning an error message if invalid. @@ -2261,18 +2254,23 @@ mod tests { } #[test] - fn jittered_base_delay_is_deterministic() { + fn jittered_base_delay_concurrent_calls_produce_different_delays() { let config = StreamRetryConfig { - jitter_factor: 0.3, + base_delay_ms: 100, + max_delay_ms: 100_000, + jitter_factor: 0.5, ..Default::default() }; - for attempt in 0..16 { - assert_eq!( - config.jittered_base_delay(attempt), - config.jittered_base_delay(attempt), - "jitter must be deterministic per attempt" - ); - } + let attempt = 1; + let mut delays: Vec<_> = (0..10) + .map(|_| config.jittered_base_delay(attempt)) + .collect(); + delays.sort(); + delays.dedup(); + assert!( + delays.len() > 1, + "concurrent calls with the same attempt must produce varied delays" + ); } #[test] From aa6f02a3bce17cdfe9d251e030eddaa22eacbd81 Mon Sep 17 00:00:00 2001 From: Iurii Bobrykov Date: Fri, 31 Jul 2026 13:34:18 +1200 Subject: [PATCH 11/20] chore: remove fallback config --- src/engine/bare.rs | 88 +++++++++++++++++++++++-------------- src/engine/bare/dispatch.rs | 5 +-- 2 files changed, 58 insertions(+), 35 deletions(-) diff --git a/src/engine/bare.rs b/src/engine/bare.rs index 685068d..52e79b3 100644 --- a/src/engine/bare.rs +++ b/src/engine/bare.rs @@ -231,11 +231,6 @@ pub struct BareLoop { /// will wake up mid-stream when cancelled. cancelled: Arc, - /// Fallback config for [`run_config`](Self::run_config) before the first - /// `run()` call. Unreachable in practice (constructors seed a placeholder - /// run), but needed so the accessor returns `&RunConfig` without panicking. - fallback_config: RunConfig, - /// Optional callback invoked for each text delta during streaming. /// /// Set via [`set_text_streamer`](BareLoop::set_text_streamer). @@ -332,17 +327,12 @@ impl BareLoop { Self { client, tools: Arc::new(tools), - session: { - let mut s = Session::new(session_config); - s.runs.push(Run::default()); - s - }, + session: Session::new(session_config), machine: LoopMachine::from_history(Vec::new()), managers, reflector: Arc::new(NoopReflector), recovery: Arc::new(ExponentialBackoffRecovery::new(3)), cancelled: Arc::new(CancelSignal::new()), - fallback_config: RunConfig::default(), text_streamer: None, contributors: Vec::new(), request_options: RequestOptions::default(), @@ -405,15 +395,27 @@ impl BareLoop { self.session.runs.get_mut(len.saturating_sub(1)) } - /// Get the run configuration for the current run. + /// Get the run configuration for the current run, if a run has started. /// /// Returns a reference to the [`RunConfig`] stored on the in-flight /// [`Run`], governing per-run budgets (turn/token limits, compaction - /// policy, dispatch mode). This is the configuration applied to the - /// most recent `run()` call. - pub fn run_config(&self) -> &RunConfig { - self.current_run() - .map_or(&self.fallback_config, |run| &run.config) + /// policy, dispatch mode). Returns `None` before the first `run()` call + /// (no run has been created yet). + pub fn run_config(&self) -> Option<&RunConfig> { + self.current_run().map(|run| &run.config) + } + + /// The parallel-dispatch config for the current run, or the default. + /// + /// Returns the [`ParallelDispatchConfig`](crate::config::ParallelDispatchConfig) + /// from the in-flight run, falling back to its default when no run has + /// started. Used by the dispatch path, which always runs inside `run()` + /// (where a run is guaranteed). + fn dispatch_mode(&self) -> crate::config::ParallelDispatchConfig { + self.run_config() + .map_or(crate::config::ParallelDispatchConfig::default(), |rc| { + rc.parallel_tool_dispatch.clone() + }) } /// Build the policy struct the machine needs for `next_step()`. @@ -422,7 +424,9 @@ impl BareLoop { /// into a single [`MachinePolicy`] passed fresh each call. fn machine_policy(&self) -> MachinePolicy { MachinePolicy { - max_turns: self.run_config().max_turns, + max_turns: self + .current_run() + .map_or(usize::MAX, |r| r.config.max_turns), context_window: self.session.config.context_window, compact_threshold: self.session.config.compact_threshold, auto_compact: self.session.config.auto_compact, @@ -469,17 +473,12 @@ impl BareLoop { Self { client, tools: Arc::new(tools), - session: { - let mut s = Session::new(session_config); - s.runs.push(Run::default()); - s - }, + session: Session::new(session_config), machine, managers: LoopManagers::new(), reflector: Arc::new(NoopReflector), recovery: Arc::new(ExponentialBackoffRecovery::new(3)), cancelled: Arc::new(CancelSignal::new()), - fallback_config: RunConfig::default(), text_streamer: None, contributors: Vec::new(), request_options: RequestOptions::default(), @@ -1770,6 +1769,7 @@ impl crate::engine::core::Loop for BareLoop { self.notify_run_start(); self.machine.accept_input(input); + let max_turns = run_config.max_turns; loop { let policy = self.machine_policy(); match self.machine.next_step(policy) { @@ -1804,9 +1804,7 @@ impl crate::engine::core::Loop for BareLoop { break; } MachineOutcome::MaxTurnsExceeded => { - let err = LoopError::MaxTurnsExceeded { - max: self.run_config().max_turns, - }; + let err = LoopError::MaxTurnsExceeded { max: max_turns }; self.finalize(Some(&err)).await?; return Err(err); } @@ -1884,11 +1882,9 @@ impl crate::engine::core::Loop for BareLoop { } match self.machine.state() { MachineState::Terminal(MachineOutcome::Failed { error }) => Some(error), - MachineState::Terminal(MachineOutcome::MaxTurnsExceeded) => { - Some(LoopError::MaxTurnsExceeded { - max: self.run_config().max_turns, - }) - } + MachineState::Terminal(MachineOutcome::MaxTurnsExceeded) => self + .run_config() + .map(|rc| LoopError::MaxTurnsExceeded { max: rc.max_turns }), MachineState::Terminal(MachineOutcome::Cancelled) => Some(LoopError::Cancelled), _ => None, } @@ -2394,6 +2390,34 @@ mod tests { assert_eq!(result.output.as_deref(), Some("Hello! I'm done.")); } + #[test] + fn run_config_is_none_before_first_run() { + let client = MockClient::new("test-model"); + let agent = BareLoop::new(Arc::new(client), ToolRegistry::new(), make_config()); + assert!( + agent.run_config().is_none(), + "run_config must be None before the first run() call" + ); + } + + #[tokio::test] + async fn run_config_is_some_after_run() { + let client = MockClient::new("test-model"); + client.add_text_response("done"); + + let config = RunConfig { + max_turns: 42, + ..RunConfig::default() + }; + let mut agent = BareLoop::new(Arc::new(client), ToolRegistry::new(), make_config()); + agent.run("hi", &config).await.unwrap(); + + let rc = agent + .run_config() + .expect("run_config must be Some after run()"); + assert_eq!(rc.max_turns, 42); + } + #[tokio::test] async fn test_bare_loop_with_tool_call() { let client = MockClient::new("test-model"); diff --git a/src/engine/bare/dispatch.rs b/src/engine/bare/dispatch.rs index 5c06b07..99b0e65 100644 --- a/src/engine/bare/dispatch.rs +++ b/src/engine/bare/dispatch.rs @@ -216,7 +216,7 @@ impl BareLoop { tool_calls: &[ToolCall], turn_idx: usize, ) -> Result, LoopError> { - match self.run_config().parallel_tool_dispatch.mode { + match self.dispatch_mode().mode { crate::config::ParallelMode::Parallel => { self.dispatch_tools_parallel(tool_calls, turn_idx).await } @@ -284,8 +284,7 @@ impl BareLoop { let plan = ToolDependencyGraph::from_calls(tool_calls, &self.tools).plan(); let max_concurrency = self - .run_config() - .parallel_tool_dispatch + .dispatch_mode() .max_concurrency .clamp(1, tool_calls.len()); let semaphore = Arc::new(tokio::sync::Semaphore::new(max_concurrency)); From cb10fb6b2b3b64848db479e8533f18ccb1936b4e Mon Sep 17 00:00:00 2001 From: Iurii Bobrykov Date: Fri, 31 Jul 2026 16:04:08 +1200 Subject: [PATCH 12/20] feat: tokeon counter trait, use heuristic counter --- src/compact.rs | 222 +++++++++++++++++++++++++++---------- src/compact/truncating.rs | 8 +- src/compact/types.rs | 12 +- src/engine/bare.rs | 61 +++++++++- src/engine/core/machine.rs | 83 ++++++++------ 5 files changed, 284 insertions(+), 102 deletions(-) diff --git a/src/compact.rs b/src/compact.rs index 83f4a60..61f0060 100644 --- a/src/compact.rs +++ b/src/compact.rs @@ -70,7 +70,117 @@ pub use types::{ EnsureContextResult, PostCompactStats, PreCompactStats, }; -/// Strategy trait for compacting a conversation's message history. +/// Strategy for estimating the token cost of a message slice. +/// +/// The engine uses a token counter in two places: the driver estimates the +/// context size after each model response (to decide whether to trigger +/// compaction), and the compactor estimates the size of the compacted +/// history (to report `tokens_after` and verify progress). Both must use the +/// same counter so the before/after comparison is consistent — mixing a +/// provider's billed token count with a heuristic estimate causes +/// compaction flapping. +/// +/// The default implementation, [`HeuristicTokenCounter`], uses a +/// characters-per-token ratio and is intentionally conservative (it +/// overestimates rather than underestimates). For production accuracy, +/// implement this trait on top of a real tokenizer (e.g. `tiktoken` for +/// OpenAI) and attach it via +/// [`BareLoop::set_token_counter`](crate::engine::BareLoop::set_token_counter) +/// and +/// [`ContextManager::with_token_counter`](ContextManager::with_token_counter). +/// +/// # Example +/// +/// ```rust +/// use loopctl::compact::{TokenCounter, HeuristicTokenCounter}; +/// use loopctl::message::Message; +/// +/// let counter = HeuristicTokenCounter; +/// let tokens = counter.count(&[Message::user("hello world")]); +/// assert!(tokens > 0); +/// ``` +pub trait TokenCounter: Send + Sync { + /// Estimate the token count for a slice of messages. + /// + /// The count should include all message content the counter considers + /// part of the context — text, tool calls, tool results — but the exact + /// set depends on the implementation. The only requirement is + /// consistency: the same counter must be used on both the trigger and + /// the post-compaction path. + fn count(&self, messages: &[Message]) -> u64; +} + +/// A zero-dependency token estimator using a characters-per-token ratio. +/// +/// Counts the character length of all message parts (text, tool calls, tool +/// results), adds a fixed per-message overhead for role tags and formatting, +/// and divides by `bytes_per_token`. Conservative — overestimates rather +/// than underestimates, so compaction triggers slightly early rather than +/// late. Accuracy is roughly ±30% on real content; for production use, +/// swap in a real tokenizer via the [`TokenCounter`] trait. +/// +/// # Presets +/// +/// A zero-dependency token estimator using 4 characters per token. +/// +/// Counts the character length of all message parts (text, tool calls, tool +/// results), adds a fixed per-message overhead for role tags and formatting, +/// and divides by 4. Conservative — overestimates rather than +/// underestimates, so compaction triggers slightly early rather than +/// late. Accuracy is roughly ±30% on real content; for production use, +/// swap in a real tokenizer via the [`TokenCounter`] trait. +/// +/// # Example +/// +/// ```rust +/// use loopctl::compact::{HeuristicTokenCounter, TokenCounter}; +/// use loopctl::message::Message; +/// +/// let counter = HeuristicTokenCounter; +/// let tokens = counter.count(&[ +/// Message::user("hello"), +/// Message::assistant("hi there"), +/// ]); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct HeuristicTokenCounter; + +impl TokenCounter for HeuristicTokenCounter { + fn count(&self, messages: &[Message]) -> u64 { + const CHARS_PER_TOKEN: u64 = 4; + const MESSAGE_OVERHEAD_CHARS: u64 = 20; + let total_chars: u64 = messages + .iter() + .map(|m| { + let part_chars: u64 = m + .parts + .iter() + .map(|p| match p { + MessagePart::Text { text } => text.chars().count() as u64, + MessagePart::Image { .. } => 256, + MessagePart::ToolCall { name, input, .. } => { + (name.len() as u64).saturating_add(input.to_string().len() as u64) + } + MessagePart::ToolResult { output, .. } => match output { + crate::message::ToolContent::Text(s) => s.len() as u64, + crate::message::ToolContent::Multipart(parts) => parts + .iter() + .map(|p| match p { + crate::message::ToolContentPart::Text { text } => { + text.len() as u64 + } + crate::message::ToolContentPart::Image { .. } => 256, + }) + .sum(), + }, + }) + .sum(); + MESSAGE_OVERHEAD_CHARS.saturating_add(part_chars) + }) + .sum(); + total_chars / CHARS_PER_TOKEN + } +} /// /// Implementations define *how* to reduce a message list — truncation, /// summarization, Q&A extraction, etc. The framework calls @@ -228,7 +338,7 @@ pub enum CompactBase { /// Message::user("Hi"), /// Message::assistant("Hello!"), /// ]; -/// let tokens = ContextManager::estimate_tokens(&messages); +/// let tokens = manager.estimate_tokens(&messages); /// assert!(!manager.should_compact(tokens)); /// ``` #[derive(Clone)] @@ -282,6 +392,15 @@ pub struct ContextManager { /// Defaults to `70` (70%); set and clamped to `[1, 100]` via /// [`with_compact_target_pct`](Self::with_compact_target_pct). compact_target: u8, + + /// Token counter used for all size estimates. + /// + /// Both the compaction trigger (checked by the driver before each model + /// call) and the post-compaction verification use this counter, ensuring + /// the before/after comparison is consistent. Defaults to + /// [`HeuristicTokenCounter`]; swap in a real tokenizer via + /// [`with_token_counter`](Self::with_token_counter). + token_counter: Arc, } impl ContextManager { @@ -296,6 +415,7 @@ impl ContextManager { /// | `auto_compact` | `true` | /// | `compact_target` | [`CompactBase::Threshold`] | /// | `compact_target_pct` | 70 (70%) | + /// | `token_counter` | [`HeuristicTokenCounter`] | #[must_use] pub fn new(compactor: Arc) -> Self { Self { @@ -305,9 +425,31 @@ impl ContextManager { auto_compact: true, compact_base: CompactBase::Threshold, compact_target: 70, + token_counter: Arc::new(HeuristicTokenCounter), } } + /// Set the token counter used for size estimates (builder-style). + /// + /// Both the compaction trigger and the post-compaction verification will + /// use this counter. Use this to plug in a real tokenizer (e.g. + /// `tiktoken` for OpenAI) for more accurate estimates than the default + /// [`HeuristicTokenCounter`]. + #[must_use] + pub fn with_token_counter(mut self, counter: Arc) -> Self { + self.token_counter = counter; + self + } + + /// Borrow the token counter. + /// + /// Exposed so the driver can use the same counter for its pre-model + /// estimate, keeping the trigger and post-compaction paths consistent. + #[must_use] + pub fn token_counter(&self) -> &Arc { + &self.token_counter + } + /// Set the model's context window size. /// /// Determines the upper bound on estimated tokens the manager @@ -429,52 +571,15 @@ impl ContextManager { base.saturating_mul(u64::from(self.compact_target)) / 100 } - /// Estimate the token count for a slice of messages. - /// - /// Uses a 4-chars-per-token heuristic based on the text content - /// of all message parts. Conservative — overestimates rather than underestimates. + /// Estimate the token count for a slice of messages using the + /// configured [`TokenCounter`]. /// - /// The estimation: - /// - Counts text content from all parts (text, tool calls, tool results). - /// - Adds a fixed overhead per message (role tags, formatting). - /// - Divides total character count by 4. + /// Delegates to [`token_counter`](Self::token_counter), so both the + /// compaction trigger (driver-side) and the post-compaction check + /// (compactor-side) use the same estimation strategy. #[must_use] - pub fn estimate_tokens(messages: &[Message]) -> u64 { - const CHARS_PER_TOKEN: u64 = 4; - const MESSAGE_OVERHEAD_CHARS: u64 = 20; // role tags, newlines, etc. - let total_chars: u64 = messages - .iter() - .map(|m| { - let part_chars: u64 = m - .parts - .iter() - .map(|p| match p { - MessagePart::Text { text } => text.chars().count() as u64, - MessagePart::Image { .. } => 256, // rough base64 estimate - MessagePart::ToolCall { name, input, .. } => { - let name_len = name.len() as u64; - let input_len = input.to_string().len() as u64; - name_len.saturating_add(input_len) - } - MessagePart::ToolResult { output, .. } => match output { - crate::message::ToolContent::Text(s) => s.len() as u64, - crate::message::ToolContent::Multipart(parts) => parts - .iter() - .map(|p| match p { - crate::message::ToolContentPart::Text { text } => { - text.len() as u64 - } - crate::message::ToolContentPart::Image { .. } => 256, - }) - .sum(), - }, - }) - .sum(); - MESSAGE_OVERHEAD_CHARS.saturating_add(part_chars) - }) - .sum(); - - total_chars / CHARS_PER_TOKEN + pub fn estimate_tokens(&self, messages: &[Message]) -> u64 { + self.token_counter.count(messages) } /// Check whether compaction should be triggered for the given token count. @@ -533,7 +638,7 @@ impl ContextManager { messages: Vec, turn: usize, ) -> Result { - let tokens_before = Self::estimate_tokens(&messages); + let tokens_before = self.estimate_tokens(&messages); if !self.should_compact(tokens_before) { return Ok(EnsureContextResult::NoAction(messages)); @@ -563,7 +668,7 @@ impl ContextManager { }); } - let tokens_after = Self::estimate_tokens(&outcome.messages); + let tokens_after = self.estimate_tokens(&outcome.messages); if tokens_after > self.context_window { return Err(ContextOverflow { tokens_used: tokens_after, @@ -611,7 +716,7 @@ impl ContextManager { turn: usize, reason: CompactReason, ) -> Result { - let tokens_before = Self::estimate_tokens(&messages); + let tokens_before = self.estimate_tokens(&messages); if messages.is_empty() { return Ok(EnsureContextResult::NoAction(messages)); @@ -668,8 +773,8 @@ impl ContextManager { post_messages: &[Message], start: Instant, ) -> CompactTelemetry { - let pre_tokens = Self::estimate_tokens(pre_messages); - let post_tokens = Self::estimate_tokens(post_messages); + let pre_tokens = CompactionOutcome::estimate_tokens(pre_messages); + let post_tokens = CompactionOutcome::estimate_tokens(post_messages); let tokens_saved = pre_tokens.saturating_sub(post_tokens); let percent_saved: u8 = tokens_saved .checked_mul(100) @@ -734,21 +839,24 @@ mod tests { #[test] fn test_estimate_tokens_empty() { - assert_eq!(ContextManager::estimate_tokens(&[]), 0); + let manager = ContextManager::new(Arc::new(TruncatingCompactor::new())); + assert_eq!(manager.estimate_tokens(&[]), 0); } #[test] fn test_estimate_tokens_single_message() { + let manager = ContextManager::new(Arc::new(TruncatingCompactor::new())); let msgs = vec![Message::user("Hello, world!")]; - let tokens = ContextManager::estimate_tokens(&msgs); - // 13 chars + 20 overhead = 33 chars / 4 = 8 tokens + let tokens = manager.estimate_tokens(&msgs); + // 13 chars + 20 overhead = 33 chars / 4 = 8 tokens (integer division) assert_eq!(tokens, 8); } #[test] fn test_estimate_tokens_multi_message() { + let manager = ContextManager::new(Arc::new(TruncatingCompactor::new())); let msgs = make_conversation(3); - let tokens = ContextManager::estimate_tokens(&msgs); + let tokens = manager.estimate_tokens(&msgs); assert!(tokens > 0); } @@ -912,7 +1020,7 @@ mod tests { .with_preserve_recent(4); let msgs = make_conversation(2); // 4 messages let context = CompactionContext { - tokens_before: ContextManager::estimate_tokens(&msgs), + tokens_before: CompactionOutcome::estimate_tokens(&msgs), reason: CompactReason::ThresholdExceeded, context_window: 1_000, turn: 1, @@ -929,7 +1037,7 @@ mod tests { .with_preserve_recent(2); let msgs = make_conversation(10); // 20 messages let first_role = msgs.first().map(|m| m.role); - let tokens_before = ContextManager::estimate_tokens(&msgs); + let tokens_before = CompactionOutcome::estimate_tokens(&msgs); let context = CompactionContext { tokens_before, reason: CompactReason::ThresholdExceeded, diff --git a/src/compact/truncating.rs b/src/compact/truncating.rs index 9b34d79..3f57541 100644 --- a/src/compact/truncating.rs +++ b/src/compact/truncating.rs @@ -6,8 +6,8 @@ //! - [`TokenSplitter`] — splits a conversation into "old" and "recent" at a turn boundary. //! - [`SplitResult`] — result of splitting a conversation. +use crate::compact::ContextCompactor; use crate::compact::types::{CompactionContext, CompactionOutcome}; -use crate::compact::{ContextCompactor, ContextManager}; use crate::message::{Message, MessagePart, Role}; use std::collections::HashSet; use std::future::Future; @@ -368,7 +368,7 @@ impl TokenSplitter { to_compact: vec![], preserved: messages.to_vec(), compact_tokens: 0, - preserved_tokens: ContextManager::estimate_tokens(messages), + preserved_tokens: CompactionOutcome::estimate_tokens(messages), split_index: 0, }; } @@ -382,8 +382,8 @@ impl TokenSplitter { SplitResult { to_compact: to_compact.to_vec(), preserved: preserved.to_vec(), - compact_tokens: ContextManager::estimate_tokens(to_compact), - preserved_tokens: ContextManager::estimate_tokens(preserved), + compact_tokens: CompactionOutcome::estimate_tokens(to_compact), + preserved_tokens: CompactionOutcome::estimate_tokens(preserved), split_index, } } diff --git a/src/compact/types.rs b/src/compact/types.rs index 41c0a58..b2da518 100644 --- a/src/compact/types.rs +++ b/src/compact/types.rs @@ -174,11 +174,17 @@ impl CompactionOutcome { /// Estimate the token count for a slice of messages. /// - /// Uses the standard 4-chars-per-token heuristic, the same - /// heuristic used by [`ContextManager::estimate_tokens`](super::ContextManager::estimate_tokens). + /// Convenience static method for compactor implementations that need to + /// self-report token counts. Uses the default + /// [`HeuristicTokenCounter`](super::HeuristicTokenCounter). The + /// [`ContextManager`](super::ContextManager) re-counts the result with + /// its own configured counter after compaction, so the self-reported + /// value is a hint — only the manager's count is authoritative for the + /// before/after comparison. #[must_use] pub fn estimate_tokens(messages: &[Message]) -> u64 { - super::ContextManager::estimate_tokens(messages) + use super::TokenCounter; + super::HeuristicTokenCounter.count(messages) } } diff --git a/src/engine/bare.rs b/src/engine/bare.rs index 52e79b3..9ac3e7d 100644 --- a/src/engine/bare.rs +++ b/src/engine/bare.rs @@ -255,6 +255,14 @@ pub struct BareLoop { /// the prior (unconstrained) behavior. Set via /// [`set_request_options`](BareLoop::set_request_options). request_options: RequestOptions, + + /// Token counter for context-size estimates. + /// + /// Used by the driver to estimate the context size after each model + /// response, which the machine compares against the compaction threshold. + /// Synced with the [`ContextManager`]'s counter when one is set, so + /// the trigger and post-compaction paths use the same estimation. + token_counter: Arc, } impl BareLoop { @@ -336,6 +344,7 @@ impl BareLoop { text_streamer: None, contributors: Vec::new(), request_options: RequestOptions::default(), + token_counter: Arc::new(crate::compact::HeuristicTokenCounter), } } @@ -482,6 +491,7 @@ impl BareLoop { text_streamer: None, contributors: Vec::new(), request_options: RequestOptions::default(), + token_counter: Arc::new(crate::compact::HeuristicTokenCounter), } } @@ -630,9 +640,53 @@ impl BareLoop { let synced = Arc::try_unwrap(manager) .unwrap_or_else(|arc| (*arc).clone()) .with_context_window(self.session.config.context_window); + self.token_counter = Arc::clone(synced.token_counter()); self.managers.set_context_manager(Arc::new(synced)); } + /// Set the token counter for context-size estimates. + /// + /// The counter is used to estimate the conversation's token cost after + /// each model response, which drives the compaction trigger. Defaults to + /// [`HeuristicTokenCounter`](crate::compact::HeuristicTokenCounter) (a + /// characters-per-token heuristic); swap in a real tokenizer (e.g. + /// `tiktoken` for OpenAI) for better accuracy. + /// + /// When a [`ContextManager`] is also set, its counter should match — use + /// [`ContextManager::with_token_counter`] on the manager before passing + /// it to [`set_context_manager`](Self::set_context_manager), which syncs + /// the two automatically. If this method is called *after* + /// `set_context_manager`, only the driver-side estimate changes (the + /// compactor keeps its own counter). + /// + /// Must be called before [`run()`](crate::engine::core::Loop::run). + /// + /// # Panics (debug only) + /// + /// In debug builds, panics if called after the session has started. + /// + /// # Example + /// + /// ```rust,ignore + /// use loopctl::compact::HeuristicTokenCounter; + /// use std::sync::Arc; + /// + /// let mut agent = BareLoop::new(client, registry, config); + /// agent.set_token_counter(Arc::new(HeuristicTokenCounter::anthropic())); + /// ``` + pub fn set_token_counter(&mut self, counter: Arc) { + self.debug_assert_idle(); + self.token_counter = counter; + } + + /// Set the token counter, consuming `self`. Fluent mirror of + /// [`set_token_counter`](Self::set_token_counter). + #[must_use] + pub fn with_token_counter(mut self, counter: Arc) -> Self { + self.set_token_counter(counter); + self + } + /// Set the [`StreamHandler`] for resilient streaming with retries, /// timeouts, and fallback to non-streaming. /// @@ -1622,7 +1676,8 @@ impl BareLoop { stop_reason, available_tools: self.tools.tool_names(), }; - self.machine.model_response(model_response); + let context_tokens = self.token_counter.count(&self.machine.full_history()); + self.machine.model_response(model_response, context_tokens); let turn_index = current_turn; let is_empty = tool_calls.is_empty(); @@ -5060,7 +5115,7 @@ mod tests { stop_reason: StopReason::ToolCall, available_tools: vec!["echo".to_string()], }; - loop_.machine.model_response(response); + loop_.machine.model_response(response, 0); let _ = loop_.machine.next_step(policy); loop_.machine.tool_results(vec![Message::user("r")]); let step = loop_.machine.next_step(policy); @@ -5102,7 +5157,7 @@ mod tests { auto_compact: true, }; let _ = loop_.machine.next_step(policy); - loop_.machine.model_response(response); + loop_.machine.model_response(response, 0); assert!(loop_.machine.is_terminal()); assert_eq!(loop_.stop_reason(), None); } diff --git a/src/engine/core/machine.rs b/src/engine/core/machine.rs index 84b0886..bc46239 100644 --- a/src/engine/core/machine.rs +++ b/src/engine/core/machine.rs @@ -319,7 +319,7 @@ pub struct MachinePolicy { /// match machine.next_step(policy) { /// MachineStep::CallLLM { .. } => { /// let response = build_response(&machine); -/// machine.model_response(response); +/// machine.model_response(response, 0); /// } /// MachineStep::CallTools { .. } => { /// machine.tool_results(Vec::new()); @@ -565,7 +565,7 @@ impl LoopMachine { /// [`MachineStep::CallTools`]. /// /// Has no effect once the machine is terminal. - pub fn model_response(&mut self, response: ModelResponse) { + pub fn model_response(&mut self, response: ModelResponse, context_tokens: u64) { if self.is_terminal() { return; } @@ -581,7 +581,7 @@ impl LoopMachine { }) .collect(); self.pending.push(message); - self.context_tokens = response.input_tokens; + self.context_tokens = context_tokens; self.turns_taken = self.turns_taken.saturating_add(1); if tool_calls.is_empty() { @@ -855,6 +855,19 @@ mod tests { } } + /// A string long enough to exceed the compaction threshold in tests that + /// use `context_window: 100, compact_threshold: 50` (trigger at 50 tokens + /// = 200 chars). The machine estimates tokens from message text now, not + /// from the provider's `input_tokens` field. + fn long_text(n: usize) -> String { + "x".repeat(n) + } + + fn count_tokens(machine: &LoopMachine) -> u64 { + use crate::compact::TokenCounter; + crate::compact::HeuristicTokenCounter.count(&machine.full_history()) + } + fn same_step(a: &MachineStep, b: &MachineStep) -> bool { serde_json::to_string(a).unwrap_or_default() == serde_json::to_string(b).unwrap_or_default() } @@ -877,7 +890,7 @@ mod tests { machine.next_step(test_policy(5)), MachineStep::CallLLM { .. } )); - machine.model_response(text_response("hi", 5, 3)); + machine.model_response(text_response("hi", 5, 3), 0); // A text-only turn completes the run, so the machine is terminal. assert!(machine.is_terminal()); assert_eq!(machine.turns_taken(), 1); @@ -894,7 +907,7 @@ mod tests { fn resume_after_model_response_round_trips() { let mut machine = small_machine(5); let _ = machine.next_step(test_policy(5)); - machine.model_response(tool_response("echo", &["echo"], 10)); + machine.model_response(tool_response("echo", &["echo"], 10), 0); let snapshot = serde_json::to_string(&machine).expect("serialize"); let mut restored: LoopMachine = serde_json::from_str(&snapshot).expect("deserialize"); let a = machine.next_step(test_policy(5)); @@ -906,7 +919,7 @@ mod tests { fn resume_after_tool_results_round_trips() { let mut machine = small_machine(5); let _ = machine.next_step(test_policy(5)); - machine.model_response(tool_response("echo", &["echo"], 10)); + machine.model_response(tool_response("echo", &["echo"], 10), 0); let step = machine.next_step(test_policy(5)); let MachineStep::CallTools { calls } = &step else { panic!("expected CallTools, got {step:?}"); @@ -940,11 +953,11 @@ mod tests { compact_threshold: 50, auto_compact: true, }; - let mut machine = LoopMachine::from_history(vec![Message::user("hello")]); + let mut machine = LoopMachine::from_history(vec![Message::user(long_text(250))]); let _ = machine.next_step(policy); // CallLLM - machine.model_response(tool_response("echo", &["echo"], 60)); // AwaitingTools + machine.model_response(tool_response("echo", &["echo"], 0), count_tokens(&machine)); // AwaitingTools let _ = machine.next_step(policy); // CallTools - machine.tool_results(vec![Message::user("tool-out")]); // → Start + machine.tool_results(vec![Message::user(long_text(250))]); // → Start assert!(matches!( machine.next_step(policy), MachineStep::Compact { .. } @@ -965,7 +978,7 @@ mod tests { machine.next_step(test_policy(2)), MachineStep::CallLLM { turn: 1 } )); - machine.model_response(tool_response("echo", &["echo"], 1)); + machine.model_response(tool_response("echo", &["echo"], 1), 0); let _ = machine.next_step(test_policy(2)); machine.tool_results(vec![Message::user("r")]); // Turn 2. @@ -973,7 +986,7 @@ mod tests { machine.next_step(test_policy(2)), MachineStep::CallLLM { turn: 2 } )); - machine.model_response(tool_response("echo", &["echo"], 1)); + machine.model_response(tool_response("echo", &["echo"], 1), 0); let _ = machine.next_step(test_policy(2)); machine.tool_results(vec![Message::user("r")]); // Turn 3 must be denied. @@ -1035,7 +1048,7 @@ mod tests { fn unknown_tool_call_gets_preresolved_result() { let mut machine = small_machine(5); let _ = machine.next_step(test_policy(5)); - machine.model_response(tool_response("ghost", &["echo", "ls"], 3)); + machine.model_response(tool_response("ghost", &["echo", "ls"], 3), 0); let step = machine.next_step(test_policy(5)); let MachineStep::CallTools { calls } = step else { panic!("expected CallTools, got {step:?}"); @@ -1056,7 +1069,7 @@ mod tests { fn known_tool_call_emits_plain_pending_call() { let mut machine = small_machine(5); let _ = machine.next_step(test_policy(5)); - machine.model_response(tool_response("echo", &["echo", "ls"], 3)); + machine.model_response(tool_response("echo", &["echo", "ls"], 3), 0); let step = machine.next_step(test_policy(5)); let MachineStep::CallTools { calls } = step else { panic!("expected CallTools, got {step:?}"); @@ -1070,24 +1083,24 @@ mod tests { #[test] fn compaction_triggered_when_tokens_exceed_threshold() { - // window = 100, threshold = 0.5 ⇒ compact once tokens > 50. + // window = 100, threshold = 50% ⇒ compact once estimate > 50 tokens. let policy = MachinePolicy { max_turns: 5, context_window: 100, compact_threshold: 50, auto_compact: true, }; - let mut machine = LoopMachine::from_history(vec![Message::user("hello")]); + let mut machine = LoopMachine::from_history(vec![Message::user(long_text(250))]); assert!(matches!( machine.next_step(policy), MachineStep::CallLLM { .. } )); - machine.model_response(tool_response("echo", &["echo"], 60)); + machine.model_response(tool_response("echo", &["echo"], 0), count_tokens(&machine)); assert!(matches!( machine.next_step(policy), MachineStep::CallTools { .. } )); - machine.tool_results(vec![Message::user("tool-out")]); + machine.tool_results(vec![Message::user(long_text(250))]); match machine.next_step(policy) { MachineStep::Compact { reason } => { assert_eq!(reason, CompactReason::ThresholdExceeded); @@ -1104,17 +1117,17 @@ mod tests { compact_threshold: 50, auto_compact: false, }; - let mut machine = LoopMachine::from_history(vec![Message::user("hello")]); + let mut machine = LoopMachine::from_history(vec![Message::user(long_text(400))]); assert!(matches!( machine.next_step(policy), MachineStep::CallLLM { .. } )); - machine.model_response(tool_response("echo", &["echo"], 96)); + machine.model_response(tool_response("echo", &["echo"], 0), count_tokens(&machine)); assert!(matches!( machine.next_step(policy), MachineStep::CallTools { .. } )); - machine.tool_results(vec![Message::user("r")]); + machine.tool_results(vec![Message::user(long_text(400))]); match machine.next_step(policy) { MachineStep::Compact { reason } => { assert_eq!(reason, CompactReason::Emergency); @@ -1132,12 +1145,12 @@ mod tests { compact_threshold: 50, auto_compact: true, }; - let mut machine = LoopMachine::from_history(vec![Message::user("hello")]); + let mut machine = LoopMachine::from_history(vec![Message::user(long_text(250))]); // Drive a tool-call turn past the threshold so the next step compacts. let _ = machine.next_step(policy); // CallLLM - machine.model_response(tool_response("echo", &["echo"], 60)); // AwaitingTools + machine.model_response(tool_response("echo", &["echo"], 0), count_tokens(&machine)); // AwaitingTools let _ = machine.next_step(policy); // CallTools - machine.tool_results(vec![Message::user("tool-out")]); // → Start + machine.tool_results(vec![Message::user(long_text(250))]); // → Start assert!(matches!( machine.next_step(policy), MachineStep::Compact { .. } @@ -1165,7 +1178,7 @@ mod tests { }; let mut machine = LoopMachine::from_history(vec![Message::user("from previous run")]); machine.accept_input("current run input"); - machine.model_response(tool_response("echo", &["echo"], 60)); + machine.model_response(tool_response("echo", &["echo"], 0), count_tokens(&machine)); let _ = machine.next_step(policy); machine.tool_results(vec![Message::user("tool-out")]); @@ -1186,11 +1199,11 @@ mod tests { compact_threshold: 50, auto_compact: true, }; - let mut machine = LoopMachine::from_history(vec![Message::user("hello")]); + let mut machine = LoopMachine::from_history(vec![Message::user(long_text(250))]); let _ = machine.next_step(policy); - machine.model_response(tool_response("echo", &["echo"], 60)); + machine.model_response(tool_response("echo", &["echo"], 0), count_tokens(&machine)); let _ = machine.next_step(policy); - machine.tool_results(vec![Message::user("tool-out")]); + machine.tool_results(vec![Message::user(long_text(250))]); assert!(matches!( machine.next_step(policy), MachineStep::Compact { .. } @@ -1217,16 +1230,16 @@ mod tests { compact_threshold: 50, auto_compact: true, }; - let mut machine = LoopMachine::from_history(vec![Message::user("hello")]); + let mut machine = LoopMachine::from_history(vec![Message::user(long_text(250))]); let _ = machine.next_step(policy); - machine.model_response(tool_response("echo", &["echo"], 60)); + machine.model_response(tool_response("echo", &["echo"], 0), count_tokens(&machine)); let _ = machine.next_step(policy); - machine.tool_results(vec![Message::user("tool-out")]); + machine.tool_results(vec![Message::user(long_text(250))]); assert!(matches!( machine.next_step(policy), MachineStep::Compact { .. } )); - machine.compaction_result(vec![Message::user("compacted")], 70); + machine.compaction_result(vec![Message::user("compacted")], 90); match machine.next_step(policy) { MachineStep::Done(MachineOutcome::Failed { @@ -1246,11 +1259,11 @@ mod tests { compact_threshold: 50, auto_compact: true, }; - let mut machine = LoopMachine::from_history(vec![Message::user("hello")]); + let mut machine = LoopMachine::from_history(vec![Message::user(long_text(250))]); let _ = machine.next_step(policy); - machine.model_response(tool_response("echo", &["echo"], 60)); + machine.model_response(tool_response("echo", &["echo"], 0), count_tokens(&machine)); let _ = machine.next_step(policy); - machine.tool_results(vec![Message::user("tool-out")]); + machine.tool_results(vec![Message::user(long_text(250))]); assert!(matches!( machine.next_step(policy), MachineStep::Compact { .. } @@ -1267,7 +1280,7 @@ mod tests { fn history_accumulates_user_assistant_tool_round() { let mut machine = small_machine(5); let _ = machine.next_step(test_policy(5)); - machine.model_response(tool_response("echo", &["echo"], 1)); + machine.model_response(tool_response("echo", &["echo"], 1), 0); let step = machine.next_step(test_policy(5)); let MachineStep::CallTools { calls } = step else { panic!("expected CallTools, got {step:?}"); From 7ce572f66150f90fbeec7fde8c3edffa5b52a789 Mon Sep 17 00:00:00 2001 From: Iurii Bobrykov Date: Fri, 31 Jul 2026 16:06:34 +1200 Subject: [PATCH 13/20] chore: use max length for memory tool input/output --- src/engine/bare/dispatch.rs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/engine/bare/dispatch.rs b/src/engine/bare/dispatch.rs index 99b0e65..868ec5c 100644 --- a/src/engine/bare/dispatch.rs +++ b/src/engine/bare/dispatch.rs @@ -4,6 +4,16 @@ //! errors, hook interception, health recording, and middleware pipeline //! support. +/// Truncate a string to `max_len` chars, appending `…` when truncated. +fn truncate_to(s: &str, max_len: usize) -> String { + if s.len() <= max_len { + return s.to_string(); + } + let mut cut = s.char_indices().take(max_len).last().map_or(0, |(i, _)| i); + cut = cut.saturating_add(s[cut..].chars().next().map_or(0, char::len_utf8)); + format!("{}…", &s[..cut]) +} + #[cfg(feature = "hooks")] use super::HookAction; use super::{ @@ -791,18 +801,18 @@ impl BareLoop { /// tool name, input, and result, then stores it. Errors are logged and /// swallowed — a memory-store failure must never crash the turn. async fn record_tool_memory(&self, tc: &ToolCall, tool_result: &ToolDispatchResult) { + const MAX_FIELD_LEN: usize = 500; let Some(memory) = self.managers.memory() else { return; }; if tool_result.is_error { return; } + let input = truncate_to(&tc.input.to_string(), MAX_FIELD_LEN); + let result = truncate_to(&tool_result.output.to_string(), MAX_FIELD_LEN); let entry = crate::memory::MemoryEntry::new( crate::memory::MemoryCategory::Trajectory, - format!( - "tool={}; input={}; result={}", - tc.tool, tc.input, tool_result.output - ), + format!("tool={}; input={input}; result={result}", tc.tool), ); if let Err(e) = memory.store(entry).await { tracing::warn!(error = %e, tool = %tc.tool, "memory store failed"); From 753a149b398d6a7e1e822603f7cf5ec90b1b1d91 Mon Sep 17 00:00:00 2001 From: Iurii Bobrykov Date: Fri, 31 Jul 2026 17:17:45 +1200 Subject: [PATCH 14/20] chore: don't drop tool calls on stream fallback, hard stop fallback to non streaming --- src/stream/handler.rs | 381 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 329 insertions(+), 52 deletions(-) diff --git a/src/stream/handler.rs b/src/stream/handler.rs index a26c29a..37738e7 100644 --- a/src/stream/handler.rs +++ b/src/stream/handler.rs @@ -36,10 +36,11 @@ use crate::api::ApiClient; use crate::api::error::{ApiError, http_status_is_overload}; use crate::cancel::CancelSignal; -use crate::message::Message; +use crate::message::{Message, MessagePart}; use crate::stream::{StreamAccumulator, StreamEvent, StreamStopReason}; use futures::StreamExt; use futures::stream::Stream; +use serde_json::Value; use std::fmt; use std::pin::Pin; use std::sync::Arc; @@ -707,7 +708,43 @@ enum RateLimitRetry { Retry(Duration), } -/// Pull the carried [`StreamOutcome`] out of a [`StreamHandlerError`], if any. +/// What `stream_turn`'s error arm should do after a failed stream event. +/// +/// Produced by [`decide_rate_limit_error`](StreamHandler::decide_rate_limit_error) +/// and [`decide_transport_error`](StreamHandler::decide_transport_error). The +/// generator body matches on this to propagate the error, try a non-streaming +/// fallback, or sleep and retry — keeping the decision logic out of the +/// `async_stream` body. +/// +/// This indirection exists because `async_stream::try_stream!` forbids +/// extracting `yield` / `?` into helper functions. The helpers return a +/// plain enum; the generator body is the only place the actual side-effect +/// (`yield`, `return`, `continue`) happens. +enum ErrorAction { + /// Propagate the error and end the stream immediately. + /// + /// Carries the [`StreamHandlerError`] the caller propagates via `?`. The + /// generator yields nothing further — the stream terminates with this + /// error as the final item. + Fail(StreamHandlerError), + + /// Attempt a non-streaming fallback before giving up. + /// + /// Carries the [`StreamOutcome`] from the failed stream attempt, which + /// [`fallback_non_streaming`](StreamHandler::fallback_non_streaming) uses + /// to build a diagnostic if the fallback also fails. The generator calls + /// the fallback, yields a [`HandlerEvent::Fallback`] on success, and + /// returns. On fallback failure the error propagates as if `Fail`. + TryFallback(Option), + + /// Sleep for the delay, then retry the outer stream loop. + /// + /// The delay is already clamped to the total-stream deadline by the + /// decision method, so the generator just calls `sleep_cancellable` and + /// `continue 'outer`. The retry counter has already been incremented. + Retry(Duration), +} + /// /// Only [`InitFailed`](StreamHandlerError::InitFailed) and /// [`StreamFailed`](StreamHandlerError::StreamFailed) carry one (the outcome @@ -1674,54 +1711,50 @@ impl StreamHandler { Ok(None) => return, Err(err) => { let last_stream_outcome = carried_outcome(&err); - - if let Some(StreamOutcome::RateLimited { detail, .. }) = &last_stream_outcome { - let rate_limit_decision = self.rate_limit_retry( + let action = if let Some(StreamOutcome::RateLimited { detail, .. }) = + &last_stream_outcome + { + self.decide_rate_limit_error( + err, detail, &mut rate_limit_retries, total_deadline, - ); - match rate_limit_decision { - RateLimitRetry::Escalate { attempts, retry_after } => { - Err(StreamHandlerError::RateLimitEscalation { - attempts, - retry_after, - })?; - return; - } - RateLimitRetry::HardStop => { - Err(err)?; - return; - } - RateLimitRetry::Retry(delay) => { - sleep_cancellable(delay, cancel).await?; - continue 'outer; - } + last_stream_outcome.clone(), + ) + } else { + self.decide_transport_error( + err, + &mut transport_attempts, + max_attempts, + last_stream_outcome.clone(), + total_deadline, + ) + }; + match action { + ErrorAction::Fail(e) => { + Err(e)?; + return; } - } - - if transport_attempts >= max_attempts.saturating_sub(1) { - if self.timeout_config.fallback_to_non_streaming { - let (message, fallback_stop_reason) = self.fallback_non_streaming( - client, - request, - cancel, - last_stream_outcome.clone(), - ).await?; + ErrorAction::TryFallback(outcome) => { + let (message, fallback_stop_reason) = self + .fallback_non_streaming( + client, + request, + cancel, + outcome, + ) + .await?; yield HandlerEvent::Fallback { message, stop_reason: fallback_stop_reason, }; return; } - Err(err)?; - return; + ErrorAction::Retry(delay) => { + sleep_cancellable(delay, cancel).await?; + continue 'outer; + } } - let delay = self.retry_config.jittered_base_delay(transport_attempts); - let delay = clamp_delay_to_deadline(delay, total_deadline); - transport_attempts = transport_attempts.saturating_add(1); - sleep_cancellable(delay, cancel).await?; - continue 'outer; } } } @@ -1742,6 +1775,78 @@ impl StreamHandler { /// /// `max_retries` is checked first so it is always enforced as the hard /// ceiling, regardless of `fallback_after_retries`. + /// Decide how to handle a rate-limit stream error. + /// + /// Delegates to [`rate_limit_retry`](Self::rate_limit_retry) for the + /// retry/escalate/hard-stop decision, then maps the result to an + /// [`ErrorAction`] the generator body can act on. + /// + /// When `HardStop` fires and + /// [`fallback_to_non_streaming`](StreamTimeoutConfig::fallback_to_non_streaming) + /// is enabled, returns [`ErrorAction::TryFallback`] instead of failing — + /// so rate-limit exhaustion gets the same fallback chance as transport + /// exhaustion. This is the symmetry fix: both exhaustion paths route + /// through the non-streaming fallback when it is configured. + fn decide_rate_limit_error( + &self, + err: StreamHandlerError, + detail: &DetectedRateLimit, + rate_limit_retries: &mut u32, + total_deadline: Option, + last_outcome: Option, + ) -> ErrorAction { + match self.rate_limit_retry(detail, rate_limit_retries, total_deadline) { + RateLimitRetry::Escalate { + attempts, + retry_after, + } => ErrorAction::Fail(StreamHandlerError::RateLimitEscalation { + attempts, + retry_after, + }), + RateLimitRetry::HardStop => { + if self.timeout_config.fallback_to_non_streaming { + ErrorAction::TryFallback(last_outcome) + } else { + ErrorAction::Fail(err) + } + } + RateLimitRetry::Retry(delay) => ErrorAction::Retry(delay), + } + } + + /// Decide how to handle a non-rate-limit transport stream error. + /// + /// Checks whether the transport-retry budget is exhausted: + /// + /// - **Exhausted + fallback enabled** → [`ErrorAction::TryFallback`]: + /// the non-streaming path gets one last chance, carrying the stream + /// outcome for diagnostics if it also fails. + /// - **Exhausted + fallback disabled** → [`ErrorAction::Fail`]: + /// propagate the error. + /// - **Retries remaining** → [`ErrorAction::Retry`]: sleep for the + /// jittered backoff (clamped to the total-stream deadline), then + /// retry. Increments `transport_attempts` so the next call knows + /// how many attempts have been spent. + fn decide_transport_error( + &self, + err: StreamHandlerError, + transport_attempts: &mut u32, + max_attempts: u32, + last_outcome: Option, + total_deadline: Option, + ) -> ErrorAction { + if *transport_attempts >= max_attempts.saturating_sub(1) { + if self.timeout_config.fallback_to_non_streaming { + return ErrorAction::TryFallback(last_outcome); + } + return ErrorAction::Fail(err); + } + let delay = self.retry_config.jittered_base_delay(*transport_attempts); + let delay = clamp_delay_to_deadline(delay, total_deadline); + *transport_attempts = transport_attempts.saturating_add(1); + ErrorAction::Retry(delay) + } + fn rate_limit_retry( &self, detail: &DetectedRateLimit, @@ -1973,14 +2078,27 @@ impl StreamHandler { match result { Ok(value) => { - // Best-effort extraction of text content from the JSON response. - let text = value + let parts = value .get("content") .and_then(|c| c.as_array()) - .and_then(|parts| { - parts + .map(|blocks| { + blocks .iter() - .find_map(|p| p.get("text").and_then(|t| t.as_str()).map(String::from)) + .filter_map(|block| match block.get("type").and_then(|t| t.as_str()) { + Some("text") => block + .get("text") + .and_then(|t| t.as_str()) + .map(MessagePart::text), + Some("tool_use") => { + let id = block.get("id").and_then(|v| v.as_str()).unwrap_or(""); + let name = + block.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let input = block.get("input").cloned().unwrap_or(Value::Null); + Some(MessagePart::tool_call(id, name, input)) + } + _ => None, + }) + .collect::>() }) .unwrap_or_default(); let stop_reason = value @@ -1988,7 +2106,19 @@ impl StreamHandler { .and_then(|r| r.as_str()) .and_then(StreamStopReason::from_api_str) .unwrap_or(StreamStopReason::EndTurn); - Ok((Message::assistant(&text), stop_reason)) + let message = if parts.is_empty() { + let text = value + .get("choices") + .and_then(|c| c.get(0)) + .and_then(|c| c.get("message")) + .and_then(|m| m.get("content")) + .and_then(|t| t.as_str()) + .unwrap_or(""); + Message::assistant(text) + } else { + Message::new(crate::message::Role::Assistant, parts) + }; + Ok((message, stop_reason)) } Err(e) => Err(StreamHandlerError::FallbackFailed { stream_outcome: stream_outcome.unwrap_or(StreamOutcome::InitFailed { @@ -2893,6 +3023,148 @@ mod tests { ); } + #[tokio::test] + async fn fallback_preserves_tool_call_parts() { + struct ToolFallbackMock; + impl ApiClient for ToolFallbackMock { + fn model(&self) -> String { + "test".to_string() + } + fn stream_messages( + &self, + _request: &crate::api::StreamRequest, + ) -> std::pin::Pin< + Box> + Send + 'static>, + > { + Box::pin(futures::stream::once(async { + Err(ApiError::api("connection refused")) + })) + } + fn create_message( + &self, + _request: &crate::api::StreamRequest, + ) -> std::pin::Pin< + Box< + dyn std::future::Future> + + Send + + '_, + >, + > { + Box::pin(async { + Ok(serde_json::json!({ + "content": [ + {"type": "text", "text": "Let me search"}, + {"type": "tool_use", "id": "tc_1", "name": "search", "input": {"q": "hello"}} + ], + "stop_reason": "tool_use" + })) + }) + } + } + + let handler = StreamHandler::new() + .with_timeout_config(StreamTimeoutConfig { + fallback_to_non_streaming: true, + ..Default::default() + }) + .with_retry_config(StreamRetryConfig { + max_retries: 0, + ..Default::default() + }); + let cancel = Arc::new(CancelSignal::new()); + let req = crate::api::StreamRequest::new(vec![]); + let mut stream = handler.stream_turn( + &ToolFallbackMock, + &req, + crate::structured::RequestOptions::default(), + &cancel, + ); + let mut got_fallback = false; + while let Some(item) = stream.next().await { + if let Ok(HandlerEvent::Fallback { message, .. }) = item { + got_fallback = true; + let has_tool = message + .parts + .iter() + .any(|p| matches!(p, crate::message::MessagePart::ToolCall { name, .. } if name == "search")); + assert!( + has_tool, + "fallback message must preserve the tool-call part, got: {:?}", + message.parts + ); + let has_text = message + .parts + .iter() + .any(|p| matches!(p, crate::message::MessagePart::Text { text } if text == "Let me search")); + assert!(has_text, "fallback message must preserve the text part"); + } + } + assert!(got_fallback, "must emit a Fallback event"); + } + + #[tokio::test] + async fn rate_limit_hard_stop_tries_fallback_when_enabled() { + struct RateLimitThenOkMock; + impl ApiClient for RateLimitThenOkMock { + fn model(&self) -> String { + "test".to_string() + } + + fn stream_messages( + &self, + _request: &crate::api::StreamRequest, + ) -> std::pin::Pin< + Box> + Send + 'static>, + > { + Box::pin(futures::stream::once(async { + Err(ApiError::RateLimit { + retry_after: None, + message: "slow down".into(), + }) + })) + } + + fn create_message( + &self, + _request: &crate::api::StreamRequest, + ) -> std::pin::Pin< + Box< + dyn std::future::Future> + + Send + + '_, + >, + > { + Box::pin(async { + Ok(serde_json::json!({ + "content": [{"type": "text", "text": "fallback ok"}], + "stop_reason": "end_turn" + })) + }) + } + } + + let handler = StreamHandler::new().with_rate_limit_config(RateLimitConfig { + fallback_after_retries: 2, + max_retries: 2, + default_delay: Duration::from_millis(1), + max_delay: Duration::from_millis(1), + ..Default::default() + }); + let cancel = Arc::new(CancelSignal::new()); + let req = crate::api::StreamRequest::new(vec![]); + let result = handler + .drive_turn(&RateLimitThenOkMock, &req, &cancel) + .await; + assert!( + result.is_ok(), + "hard-stop must try fallback when enabled, got: {:?}", + result.err() + ); + let drive = result.unwrap(); + assert!(drive.from_fallback); + assert!(drive.message.text_content().contains("fallback ok")); + } + /// Mock that fails its first streaming attempt with a transport error, /// then succeeds on the second. Used by the AttemptReset test to verify /// the handler emits `AttemptReset` before the retried attempt's events. @@ -4160,13 +4432,18 @@ mod tests { } } - let handler = StreamHandler::new().with_rate_limit_config(RateLimitConfig { - fallback_after_retries: 2, - max_retries: 2, - default_delay: Duration::from_millis(1), - max_delay: Duration::from_millis(1), - ..Default::default() - }); + let handler = StreamHandler::new() + .with_timeout_config(StreamTimeoutConfig { + fallback_to_non_streaming: false, + ..Default::default() + }) + .with_rate_limit_config(RateLimitConfig { + fallback_after_retries: 2, + max_retries: 2, + default_delay: Duration::from_millis(1), + max_delay: Duration::from_millis(1), + ..Default::default() + }); let client = AlwaysRateLimitMock; let cancel = Arc::new(CancelSignal::new()); From 27c9c321f5e7c38462a8db867a69d502c7b87770 Mon Sep 17 00:00:00 2001 From: Iurii Bobrykov Date: Fri, 31 Jul 2026 23:28:43 +1200 Subject: [PATCH 15/20] chore: no streaming response, create message to return typed message --- CHANGELOG.md | 69 +++ src/api.rs | 192 +++++++-- src/compact.rs | 4 + src/compact/truncating.rs | 25 +- src/compact/types.rs | 19 +- src/engine/bare.rs | 79 +++- src/engine/bare/dispatch.rs | 27 +- src/engine/bare/message.rs | 4 +- src/engine/bare/stream.rs | 17 +- src/engine/core/lifecycle.rs | 9 + src/engine/core/machine.rs | 3 + src/message.rs | 21 +- src/provider.rs | 1 + src/provider/anthropic.rs | 293 +++++++++++-- src/provider/gemini.rs | 517 +++++++++++++++++++++-- src/provider/openai.rs | 785 ++++++++++++++++++++++++++++++----- src/reflection/llm.rs | 83 +++- src/stream.rs | 25 +- src/stream/handler.rs | 348 ++++++++++------ src/structured.rs | 89 +++- src/testing.rs | 36 +- 21 files changed, 2212 insertions(+), 434 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f11c03..f83ad21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -240,6 +240,75 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/2.0.0. ### Changed +- **Breaking (`create_message` returns a typed response):** + `ApiClient::create_message` and `ApiClient::create_message_with_options` + now return `Result` instead of + `Result`. `NonStreamingResponse` carries the + fully assembled `message: Message`, a typed `stop_reason: + StreamStopReason`, and the token `usage: Usage` — the typed counterpart to + a streamed response. Each provider builds this from its own native JSON, so + no provider-specific parsing remains at the call site (the handler's + `fallback_non_streaming` collapsed from a 30-line JSON parser to a single + field access). Token usage is now extracted from each provider's native + usage field (`usage.prompt_tokens`/`completion_tokens` on OpenAI, + `usage.input_tokens`/`output_tokens` on Anthropic, + `usageMetadata.promptTokenCount`/`candidatesTokenCount`/`thoughtsTokenCount` + on Gemini), and threaded through `HandlerEvent::Fallback` so the fallback + path reports real token counts instead of `None`. + Migration: read `.message`, `.stop_reason`, and `.usage` from the returned + struct instead of indexing into the JSON value. +- **Breaking (`extract_structured` takes `&Message`):** + `ApiClient::extract_structured` now takes `&Message` instead of + `&serde_json::Value` and has a default implementation that derives the + payload from the message (first tool-call `input`, else text lenient-parsed + as JSON). The per-provider overrides (`OpenAiClient`, `AnthropicClient`, + `GeminiClient`) and the shared `extract_structured_from_normalized` helper + are removed — the single default works for every provider because the + provider-specific envelope is gone by the time a typed `Message` exists. + Migration: if you override `extract_structured`, change the parameter from + `&serde_json::Value` to `&Message`; most overrides can be deleted in favor + of the default. +- `StreamStopReason::from_api_str` now accepts `"tool_use"` as an alias for + `"tool_call"`. Anthropic reports a tool-invocation stop reason as + `"tool_use"` (while OpenAI uses `"tool_calls"`); both now map to `ToolCall`. + This fixes a pre-existing bug where Anthropic tool-call responses were + misclassified as `EndTurn` on both the streaming and non-streaming paths. +- `Usage` now derives `PartialEq` and `Eq`. +- **OpenAI streaming usage:** the OpenAI streaming client now sets + `stream_options.include_usage` on streaming requests and captures the token + counts from the final usage chunk. Previously the streaming path always + reported `usage: None` (it discarded usage), so a streamed turn reported zero + tokens while a non-streaming fallback reported real counts — the successful + path reported less than the fallback. The `StreamEmitter` now defers the + `MessageDelta` until either the usage chunk arrives or the stream ends, + emitting the stop reason and usage together. All three providers (OpenAI, + Anthropic, Gemini) now report usage on both streaming and non-streaming paths. + `stream_options.include_usage` is opt-in via + `OpenAiClientBuilder::with_stream_usage(false)` for OpenAI-compatible servers + that reject the parameter; the `ollama()` constructor disables it + automatically for compatibility with older Ollama versions. +- **Breaking (`NonStreamingResponse.usage` is now `Option`):** the field + changed from `Usage` to `Option`, symmetric with the streaming path's + `Option`. This lets callers distinguish "provider reported zero tokens" + from "provider omitted usage." `None` means the provider did not include a + usage object in its response. Migration: unwrap with `.unwrap_or_default()` + or pattern-match on `Option`. +- **Breaking (`MessagePart::ToolResult` gains a `name` field):** tool results + now carry the tool's function name alongside the `call_id`. Required by Gemini + (and other providers) that correlate tool responses by function name in + `functionResponse`, not just by call id. `MessagePart::tool_result()` gains a + `name` parameter (second argument, after `call_id`). + Migration: add the tool name as the second argument to every + `MessagePart::tool_result()` call. +- **Gemini tool-call ids:** the Gemini provider now parses `functionCall.id` + (Gemini 3 returns a unique id per call) and echoes it in + `functionResponse.id`, fixing tool-response correlation for parallel calls. + The `functionResponse` serialization now sends both `name` (the function name) + and `id` (the call id) instead of putting the call id in `name`. + Migration: none — serialization is automatic. +- **OpenAI usage unification:** the non-streaming `build_response` path now uses + the same `OpenAiUsage` serde struct as the streaming path, deleting the manual + `extract_usage` helper. Both paths share one deserialization strategy. - **Breaking (session→run lifecycle rename):** the observer and hook events formerly named `on_session_start` / `on_session_end` are renamed to `on_run_start` / `on_run_end`. These events fire once per `run()` call and diff --git a/src/api.rs b/src/api.rs index db674cf..dd8a052 100644 --- a/src/api.rs +++ b/src/api.rs @@ -7,7 +7,7 @@ pub mod error; use crate::message::Message; -use crate::stream::StreamEvent; +use crate::stream::{StreamEvent, StreamStopReason, Usage}; use crate::tool::ToolSchema; use error::ApiError; use futures::Stream; @@ -107,6 +107,42 @@ impl StreamRequest { } } +/// A completed, non-streaming LLM response. +/// +/// The typed counterpart to a streamed response: instead of a sequence of +/// [`StreamEvent`]s, the complete assistant [`Message`], the reason +/// generation stopped, and the token [`Usage`] are delivered in one shot. +/// Each provider builds this from its own native JSON envelope, so callers +/// never see provider-specific shapes — exactly mirroring how the streaming +/// path emits typed events. +/// +/// Produced by [`create_message`](ApiClient::create_message) and +/// [`create_message_with_options`](ApiClient::create_message_with_options). +#[derive(Debug, Clone)] +pub struct NonStreamingResponse { + /// The fully assembled assistant message. + /// + /// Contains the same [`MessagePart`](crate::message::MessagePart) sequence + /// a stream would accumulate — text blocks, tool calls, etc. Built by the + /// provider from its native response shape. + pub message: Message, + + /// Why the model stopped generating. + /// + /// Mapped by the provider from its native finish/stop field. Drives the + /// agent loop's decision to continue to tool execution or end the turn. + pub stop_reason: StreamStopReason, + + /// Token counts for the request, as reported by the provider. + /// + /// Extracted from the provider's native usage field (`usage` on OpenAI and + /// Anthropic, `usageMetadata` on Gemini). `None` when the provider omits + /// usage from the response — symmetric with the `Option` carried by + /// the final [`MessageDelta`](StreamEvent::MessageDelta) event on the + /// streaming path. + pub usage: Option, +} + /// Interface for API clients that communicate with LLM providers. /// /// Defines the contract for both streaming and non-streaming @@ -158,7 +194,7 @@ impl StreamRequest { /// fn create_message( /// &self, /// request: StreamRequest, -/// ) -> Pin> + Send + '_>> { +/// ) -> Pin> + Send + '_>> { /// // Non-streaming fallback /// todo!() /// } @@ -235,22 +271,24 @@ pub trait ApiClient: Send + Sync { /// Non-streaming message request (fallback). /// /// Sends the same [`StreamRequest`] as - /// [`stream_messages`](ApiClient::stream_messages) but returns a single - /// [`serde_json::Value`] instead of a stream. Useful for simple one-shot - /// queries where streaming overhead isn't needed, or as a fallback when - /// the provider does not support streaming. + /// [`stream_messages`](ApiClient::stream_messages) but returns a fully + /// assembled [`NonStreamingResponse`] instead of a stream. Useful for + /// simple one-shot queries where streaming overhead isn't needed, or as a + /// fallback when the provider does not support streaming. Each provider + /// builds the typed [`Message`] from its own native JSON, so no + /// provider-specific parsing is required at the call site. /// /// Called by utility code that needs a complete response in one shot, /// such as token estimation probes or health checks. /// /// # Returns /// - /// A pinned, boxed future resolving to the raw JSON response value - /// from the provider, or an [`ApiError`] if the request fails. + /// A pinned, boxed future resolving to the typed + /// [`NonStreamingResponse`], or an [`ApiError`] if the request fails. fn create_message( &self, request: &StreamRequest, - ) -> Pin> + Send + '_>>; + ) -> Pin> + Send + '_>>; /// Streaming variant that honors [`RequestOptions`](crate::structured::RequestOptions). /// @@ -292,7 +330,7 @@ pub trait ApiClient: Send + Sync { &self, request: &StreamRequest, options: crate::structured::RequestOptions, - ) -> Pin> + Send + '_>> { + ) -> Pin> + Send + '_>> { if options.response_format.is_none() { return self.create_message(request); } @@ -303,17 +341,23 @@ pub trait ApiClient: Send + Sync { }) } - /// Extract the structured-output payload from a provider response. + /// Extract the structured-output payload from an assistant message. /// - /// Each provider knows its own response envelope shape. This method pulls - /// the inner JSON value that should be fed to + /// Returns the JSON value that should be fed to /// [`StructuredOutput::from_value`](crate::structured::StructuredOutput::from_value). - /// The default implementation returns the raw value as-is (for mock - /// clients and custom providers that already return the structured value - /// without an envelope). `OpenAiClient`, `AnthropicClient`, and - /// `GeminiClient` override this to navigate their respective envelopes. - fn extract_structured(&self, raw: &serde_json::Value) -> serde_json::Value { - raw.clone() + /// The default implementation derives this purely from the typed + /// [`Message`], so it works for every provider without overrides: the + /// first [`ToolCall`](crate::message::MessagePart::ToolCall) part's `input` + /// when present, otherwise the joined + /// [`text_content`](Message::text_content) lenient-parsed as JSON. When the + /// text is not valid JSON — plain prose, or an empty message — the raw text + /// string is returned as-is, which downstream deserialization will reject. + fn extract_structured(&self, message: &Message) -> serde_json::Value { + if let Some((_, _, input)) = message.tool_call_parts().into_iter().next() { + return input.clone(); + } + let text = message.text_content(); + crate::structured::parse_json_lenient(&text).unwrap_or(serde_json::Value::String(text)) } } @@ -407,12 +451,14 @@ mod tests { fn create_message( &self, _request: &StreamRequest, - ) -> Pin> + Send + '_>> + ) -> Pin> + Send + '_>> { Box::pin(async { - Ok(serde_json::json!({ - "content": [{"type": "text", "text": "Hello!"}] - })) + Ok(NonStreamingResponse { + message: Message::assistant("Hello!"), + stop_reason: StreamStopReason::EndTurn, + usage: Some(Usage::default()), + }) }) } } @@ -463,8 +509,9 @@ mod tests { }) .await; assert!(result.is_ok()); - let json = result.unwrap(); - assert!(json.get("content").is_some()); + let response = result.unwrap(); + assert!(!response.message.parts.is_empty()); + assert_eq!(response.stop_reason, StreamStopReason::EndTurn); } #[test] @@ -516,4 +563,99 @@ mod tests { assert!(!client.set_model("other-model")); assert_eq!(client.model(), "test-model"); } + + #[test] + fn extract_structured_default_returns_tool_call_input() { + let client = MockClient::new("m"); + let message = Message::new( + crate::message::Role::Assistant, + vec![crate::message::MessagePart::tool_call( + "tc_1", + "search", + serde_json::json!({"q": "rust"}), + )], + ); + let value = client.extract_structured(&message); + assert_eq!(value, serde_json::json!({"q": "rust"})); + } + + #[test] + fn extract_structured_default_returns_first_tool_call_input() { + let client = MockClient::new("m"); + let message = Message::new( + crate::message::Role::Assistant, + vec![ + crate::message::MessagePart::tool_call( + "tc_1", + "first", + serde_json::json!({"order": 1}), + ), + crate::message::MessagePart::tool_call( + "tc_2", + "second", + serde_json::json!({"order": 2}), + ), + ], + ); + let value = client.extract_structured(&message); + assert_eq!(value, serde_json::json!({"order": 1})); + } + + #[test] + fn extract_structured_default_parses_text_as_json() { + let client = MockClient::new("m"); + let message = Message::assistant(r#"{"tool": "write", "args": {}}"#); + let value = client.extract_structured(&message); + assert_eq!(value["tool"], "write"); + } + + #[test] + fn extract_structured_default_lenient_parses_embedded_json() { + let client = MockClient::new("m"); + let message = Message::assistant(r#"Here is the result: {"answer": 42}"#); + let value = client.extract_structured(&message); + assert_eq!(value["answer"], 42); + } + + #[test] + fn extract_structured_default_prose_falls_back_to_string() { + let client = MockClient::new("m"); + let message = Message::assistant("just prose, no json here"); + let value = client.extract_structured(&message); + assert_eq!(value, serde_json::json!("just prose, no json here")); + } + + #[test] + fn extract_structured_default_empty_message_falls_back_to_empty_string() { + let client = MockClient::new("m"); + let message = Message::assistant(""); + let value = client.extract_structured(&message); + assert_eq!(value, serde_json::json!("")); + } + + #[test] + fn non_streaming_response_fields_are_accessible() { + let response = NonStreamingResponse { + message: Message::assistant("hello"), + stop_reason: StreamStopReason::EndTurn, + usage: Some(Usage::new(100, 50)), + }; + assert_eq!(response.message.text_content(), "hello"); + assert_eq!(response.stop_reason, StreamStopReason::EndTurn); + let usage = response.usage.expect("usage present"); + assert_eq!(usage.input_tokens, 100); + assert_eq!(usage.output_tokens, 50); + assert_eq!(usage.total_tokens(), 150); + assert!(!response.message.parts.is_empty()); + } + + #[test] + fn non_streaming_response_usage_can_be_none() { + let response = NonStreamingResponse { + message: Message::assistant("hello"), + stop_reason: StreamStopReason::EndTurn, + usage: None, + }; + assert!(response.usage.is_none()); + } } diff --git a/src/compact.rs b/src/compact.rs index 61f0060..7ef8ba3 100644 --- a/src/compact.rs +++ b/src/compact.rs @@ -652,6 +652,7 @@ impl ContextManager { reason, context_window: self.context_window, turn, + counter: Arc::clone(&self.token_counter), }; let outcome = self .compactor @@ -729,6 +730,7 @@ impl ContextManager { reason, context_window: self.context_window, turn, + counter: Arc::clone(&self.token_counter), }; let outcome = self .compactor @@ -1024,6 +1026,7 @@ mod tests { reason: CompactReason::ThresholdExceeded, context_window: 1_000, turn: 1, + counter: Arc::new(HeuristicTokenCounter), }; let outcome = compactor.compact(msgs.clone(), 500, context).await; assert!(outcome.success); @@ -1043,6 +1046,7 @@ mod tests { reason: CompactReason::ThresholdExceeded, context_window: 1_000, turn: 5, + counter: Arc::new(HeuristicTokenCounter), }; let outcome = compactor.compact(msgs, 500, context).await; assert!(outcome.success); diff --git a/src/compact/truncating.rs b/src/compact/truncating.rs index 3f57541..7537faa 100644 --- a/src/compact/truncating.rs +++ b/src/compact/truncating.rs @@ -157,7 +157,7 @@ impl ContextCompactor for TruncatingCompactor { recent }; - let tokens_after = CompactionOutcome::estimate_tokens(&preserved); + let tokens_after = context.counter.count(&preserved); CompactionOutcome { messages: preserved, tokens_after, @@ -444,6 +444,7 @@ mod tests { reason: CompactReason::ThresholdExceeded, context_window: 1_000, turn: 5, + counter: std::sync::Arc::new(crate::compact::HeuristicTokenCounter), } } @@ -466,6 +467,7 @@ mod tests { Role::User, vec![MessagePart::tool_result( "call_a", + "search", tool_text("result data"), false, )], @@ -524,7 +526,12 @@ mod tests { ), Message::new( Role::User, - vec![MessagePart::tool_result("call_b", tool_text("42"), false)], + vec![MessagePart::tool_result( + "call_b", + "calc", + tool_text("42"), + false, + )], ), ]; @@ -557,7 +564,12 @@ mod tests { ), Message::new( Role::User, - vec![MessagePart::tool_result("call_c", tool_text("done"), false)], + vec![MessagePart::tool_result( + "call_c", + "tool", + tool_text("done"), + false, + )], ), Message::assistant("reply1"), Message::user("msg2"), @@ -622,7 +634,12 @@ mod tests { ), Message::new( Role::User, - vec![MessagePart::tool_result("call_d", tool_text("ok"), false)], + vec![MessagePart::tool_result( + "call_d", + "tool", + tool_text("ok"), + false, + )], ), ]; let compactor = TruncatingCompactor::new() diff --git a/src/compact/types.rs b/src/compact/types.rs index b2da518..6a02b27 100644 --- a/src/compact/types.rs +++ b/src/compact/types.rs @@ -10,9 +10,11 @@ //! - [`ContextOverflow`] — error when the conversation cannot fit. //! - [`EnsureContextResult`] — result of [`ContextManager::ensure_context_fits`](super::ContextManager::ensure_context_fits). +use crate::compact::TokenCounter; use crate::message::Message; use serde::{Deserialize, Serialize}; use std::fmt; +use std::sync::Arc; /// Why compaction was triggered. /// @@ -62,13 +64,13 @@ impl fmt::Display for CompactReason { /// Compactors can use this information to decide how aggressively to /// compact — e.g. an emergency compaction may use more aggressive /// summarization than a routine threshold check. -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct CompactionContext { /// Estimated token count before compaction. /// - /// The compactor's input size, computed with the crate's standard - /// 4-chars-per-token heuristic. Compaction aims to bring the post-compaction - /// size below this so the conversation fits with headroom for the next turn. + /// The compactor's input size, computed by the configured + /// [`TokenCounter`]. Compaction aims to bring the post-compaction size + /// below this so the conversation fits with headroom for the next turn. pub tokens_before: u64, /// Why compaction was triggered. @@ -91,6 +93,15 @@ pub struct CompactionContext { /// recent turns more heavily, or for correlating a compaction pass back to /// the turn that triggered it in logs. pub turn: usize, + + /// The token counter for estimating message sizes. + /// + /// The same counter the driver uses for its compaction trigger — so the + /// compactor can self-report `tokens_after` consistently. Compact + /// implementations should use `context.counter.count(&messages)` instead + /// of the static [`CompactionOutcome::estimate_tokens`] to match the + /// driver's configured counter. + pub counter: Arc, } /// Result of a single compaction pass. diff --git a/src/engine/bare.rs b/src/engine/bare.rs index 9ac3e7d..a1e0ac3 100644 --- a/src/engine/bare.rs +++ b/src/engine/bare.rs @@ -1900,6 +1900,7 @@ impl crate::engine::core::Loop for BareLoop { Box::pin(async move { if let Some(run) = self.current_run_mut() { run.end = Some(Instant::now()); + run.stop_reason = error.cloned(); } if error.is_none() { @@ -2232,8 +2233,18 @@ mod tests { fn create_message( &self, _request: &crate::api::StreamRequest, - ) -> Pin> + Send + '_>> { - Box::pin(async { Ok(json!({"content": []})) }) + ) -> Pin< + Box< + dyn Future> + Send + '_, + >, + > { + Box::pin(async { + Ok(crate::api::NonStreamingResponse { + message: crate::message::Message::assistant(""), + stop_reason: crate::stream::StreamStopReason::EndTurn, + usage: Some(crate::stream::Usage::default()), + }) + }) } } @@ -2613,8 +2624,18 @@ mod tests { fn create_message( &self, _request: &crate::api::StreamRequest, - ) -> Pin> + Send + '_>> { - Box::pin(async { Ok(json!({"content": []})) }) + ) -> Pin< + Box< + dyn Future> + Send + '_, + >, + > { + Box::pin(async { + Ok(crate::api::NonStreamingResponse { + message: crate::message::Message::assistant(""), + stop_reason: crate::stream::StreamStopReason::EndTurn, + usage: Some(crate::stream::Usage::default()), + }) + }) } } @@ -3345,6 +3366,7 @@ mod tests { match &parts[0] { MessagePart::ToolResult { call_id, + name: _, output, is_error, } => { @@ -4503,7 +4525,11 @@ mod tests { fn create_message( &self, _request: &crate::api::StreamRequest, - ) -> Pin> + Send + '_>> { + ) -> Pin< + Box< + dyn Future> + Send + '_, + >, + > { Box::pin(async { Err(ApiError::api("not implemented")) }) } } @@ -4717,12 +4743,19 @@ mod tests { _request: &crate::api::StreamRequest, ) -> Pin< Box< - dyn std::future::Future> - + Send + dyn std::future::Future< + Output = Result, + > + Send + '_, >, > { - Box::pin(async { Ok(serde_json::Value::Null) }) + Box::pin(async { + Ok(crate::api::NonStreamingResponse { + message: crate::message::Message::assistant(""), + stop_reason: crate::stream::StreamStopReason::EndTurn, + usage: Some(crate::stream::Usage::default()), + }) + }) } } @@ -5378,8 +5411,20 @@ mod tests { fn create_message( &self, _request: &crate::api::StreamRequest, - ) -> Pin> + Send + '_>> { - Box::pin(async { Ok(json!({})) }) + ) -> Pin< + Box< + dyn Future> + + Send + + '_, + >, + > { + Box::pin(async { + Ok(crate::api::NonStreamingResponse { + message: crate::message::Message::assistant(""), + stop_reason: crate::stream::StreamStopReason::EndTurn, + usage: Some(crate::stream::Usage::default()), + }) + }) } } @@ -5603,8 +5648,18 @@ mod tests { fn create_message( &self, _request: &crate::api::StreamRequest, - ) -> Pin> + Send + '_>> { - Box::pin(async { Ok(json!({"content": []})) }) + ) -> Pin< + Box< + dyn Future> + Send + '_, + >, + > { + Box::pin(async { + Ok(crate::api::NonStreamingResponse { + message: crate::message::Message::assistant(""), + stop_reason: crate::stream::StreamStopReason::EndTurn, + usage: Some(crate::stream::Usage::default()), + }) + }) } } diff --git a/src/engine/bare/dispatch.rs b/src/engine/bare/dispatch.rs index 868ec5c..2c4c1bb 100644 --- a/src/engine/bare/dispatch.rs +++ b/src/engine/bare/dispatch.rs @@ -333,14 +333,18 @@ impl BareLoop { Ok(results .into_iter() - .map(|r| { - r.unwrap_or_else(|| ToolDispatchResult { - tool_call_id: String::new(), - output: ToolContent::Text("dispatch produced no result".to_string()), - is_error: true, - duration: Duration::ZERO, - resolved_tool_name: String::new(), - display_hint: None, + .enumerate() + .map(|(idx, r)| { + r.unwrap_or_else(|| { + let tc = tool_calls.get(idx); + ToolDispatchResult { + tool_call_id: tc.map(|c| c.id.clone()).unwrap_or_default(), + output: ToolContent::Text("dispatch produced no result".to_string()), + is_error: true, + duration: Duration::ZERO, + resolved_tool_name: tc.map(|c| c.tool.clone()).unwrap_or_default(), + display_hint: None, + } }) }) .collect()) @@ -983,8 +987,11 @@ mod tests { fn create_message( &self, _request: &crate::api::StreamRequest, - ) -> Pin> + Send + '_>> - { + ) -> Pin< + Box< + dyn Future> + Send + '_, + >, + > { Box::pin(async { Err(ApiError::http("not implemented")) }) } } diff --git a/src/engine/bare/message.rs b/src/engine/bare/message.rs index 3d56d91..ce58ab9 100644 --- a/src/engine/bare/message.rs +++ b/src/engine/bare/message.rs @@ -23,7 +23,9 @@ impl BareLoop { pub(super) fn build_tool_result_parts(results: Vec) -> Vec { results .into_iter() - .map(|r| MessagePart::tool_result(r.tool_call_id, r.output, r.is_error)) + .map(|r| { + MessagePart::tool_result(r.tool_call_id, r.resolved_tool_name, r.output, r.is_error) + }) .collect() } diff --git a/src/engine/bare/stream.rs b/src/engine/bare/stream.rs index 8d3ddd7..8e7efa1 100644 --- a/src/engine/bare/stream.rs +++ b/src/engine/bare/stream.rs @@ -79,8 +79,9 @@ impl BareLoop { HandlerEvent::Fallback { message, stop_reason: fallback_stop_reason, + usage: fallback_usage, } => { - return Ok((message, None, fallback_stop_reason)); + return Ok((message, fallback_usage, fallback_stop_reason)); } } } @@ -197,9 +198,19 @@ mod tests { &self, _request: &crate::api::StreamRequest, ) -> std::pin::Pin< - Box> + Send + '_>, + Box< + dyn std::future::Future> + + Send + + '_, + >, > { - Box::pin(async { Ok(serde_json::json!({})) }) + Box::pin(async { + Ok(crate::api::NonStreamingResponse { + message: crate::message::Message::assistant(""), + stop_reason: crate::stream::StreamStopReason::EndTurn, + usage: Some(crate::stream::Usage::default()), + }) + }) } } diff --git a/src/engine/core/lifecycle.rs b/src/engine/core/lifecycle.rs index 627214f..1aa3afa 100644 --- a/src/engine/core/lifecycle.rs +++ b/src/engine/core/lifecycle.rs @@ -370,6 +370,14 @@ pub struct Run { /// `run()` call that started this run. Captured so a serialized run /// carries its governing config. pub config: RunConfig, + + /// Why the run ended, if it has terminated. + /// + /// `None` while the run is in flight or completed normally. Set to the + /// terminal [`LoopError`] (`Cancelled`, `MaxTurnsExceeded`, etc.) when + /// the run ended abnormally. Populated by the engine in `finalize`. + #[serde(skip)] + pub stop_reason: Option, } impl Run { @@ -402,6 +410,7 @@ impl Run { input: input.into(), output: None, config: config.clone(), + stop_reason: None, } } diff --git a/src/engine/core/machine.rs b/src/engine/core/machine.rs index bc46239..20add84 100644 --- a/src/engine/core/machine.rs +++ b/src/engine/core/machine.rs @@ -809,6 +809,7 @@ impl LoopMachine { Role::User, vec![MessagePart::tool_result( call.id.clone(), + call.tool.clone(), ToolContent::Text(message), true, )], @@ -931,6 +932,7 @@ mod tests { Role::User, vec![MessagePart::tool_result( c.call.id.clone(), + c.call.tool.clone(), ToolContent::Text("ok".to_string()), false, )], @@ -1292,6 +1294,7 @@ mod tests { .map(|c| { MessagePart::tool_result( c.call.id.clone(), + c.call.tool.clone(), ToolContent::Text("ok".to_string()), false, ) diff --git a/src/message.rs b/src/message.rs index 87ed2d5..6a2289e 100644 --- a/src/message.rs +++ b/src/message.rs @@ -52,7 +52,7 @@ //! let tool_result_msg = Message { //! role: Role::User, // tool results are sent back as "user" role //! parts: vec![ -//! MessagePart::tool_result("tool_1", ToolContent::from_string("file1.txt\nfile2.txt"), false), +//! MessagePart::tool_result("tool_1", "list_files", ToolContent::from_string("file1.txt\nfile2.txt"), false), //! ], //! }; //! ``` @@ -324,7 +324,7 @@ impl fmt::Display for Role { /// use loopctl::message::{MessagePart}; /// let text_part = MessagePart::text("Hello"); /// let tool_part = MessagePart::tool_call("id1", "search", serde_json::json!({"q": "test"})); -/// let result_part = MessagePart::tool_result("id1", "found 3 results", false); +/// let result_part = MessagePart::tool_result("id1", "search", "found 3 results", false); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type")] @@ -409,6 +409,15 @@ pub enum MessagePart { /// [`ToolCall`](MessagePart::ToolCall) block. call_id: String, + /// The name of the tool that produced this result. + /// + /// Copied from the original [`ToolCall`](MessagePart::ToolCall)'s + /// `name` field. Required by providers that correlate tool responses + /// by function name (e.g. Gemini's `functionResponse`), in addition to + /// the `call_id`. Always present for results built by the engine after + /// tool dispatch. + name: String, + /// The output returned by the tool. /// /// Can be a simple string or a multipart response with @@ -492,6 +501,7 @@ impl MessagePart { /// # Arguments /// /// - `call_id` — The ID of the tool invocation this output is for. + /// - `name` — The name of the tool that produced this result. /// - `output` — The tool output (string or multipart). /// - `is_error` — Whether the tool invocation failed. /// @@ -505,6 +515,7 @@ impl MessagePart { /// use loopctl::message::{MessagePart}; /// let part = MessagePart::tool_result( /// "tool_abc", + /// "read_file", /// "file contents here", /// false, /// ); @@ -512,11 +523,13 @@ impl MessagePart { /// ``` pub fn tool_result( call_id: impl Into, + name: impl Into, output: impl Into, is_error: bool, ) -> Self { Self::ToolResult { call_id: call_id.into(), + name: name.into(), output: output.into(), is_error: Some(is_error), } @@ -565,7 +578,7 @@ impl MessagePart { /// /// ```rust /// use loopctl::message::{MessagePart}; - /// let part = MessagePart::tool_result("id", "output", false); + /// let part = MessagePart::tool_result("id", "search", "output", false); /// assert!(part.is_tool_result()); /// ``` #[must_use] @@ -1006,7 +1019,7 @@ mod tests { assert!(!tool_call.is_text()); assert!(tool_call.as_text().is_none()); - let tool_result = MessagePart::tool_result("id", "ok", false); + let tool_result = MessagePart::tool_result("id", "tool", "ok", false); assert!(tool_result.is_tool_result()); } diff --git a/src/provider.rs b/src/provider.rs index 3c45492..752b1a4 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -498,6 +498,7 @@ pub fn ollama(model: &str) -> Result { .with_api_key(api_key) .with_base_url(base) .with_model(model) + .with_stream_usage(false) .build() } diff --git a/src/provider/anthropic.rs b/src/provider/anthropic.rs index ac42f99..127a9aa 100644 --- a/src/provider/anthropic.rs +++ b/src/provider/anthropic.rs @@ -190,6 +190,72 @@ impl AnthropicClient { fn messages_url(&self) -> String { format!("{}/v1/messages", self.base_url) } + + /// Build a typed [`NonStreamingResponse`] from Anthropic's native JSON. + /// + /// Anthropic's native response already carries a `content` array of typed + /// blocks, so this reads them directly into [`MessagePart`]s: `text` blocks + /// become [`MessagePart::Text`] parts and `tool_use` blocks become + /// [`MessagePart::ToolCall`] parts, preserving their original order. Other + /// block types (`thinking`, `redacted_thinking`) are skipped — reasoning is + /// stream-only in this crate and is not accumulated into the message. + /// Maps `stop_reason` via [`StreamStopReason::from_api_str`] (Anthropic + /// reports tool invocations as `"tool_use"`, aliased to `ToolCall`), + /// defaulting to `EndTurn` on an unrecognized or missing value. Reads + /// `usage.input_tokens` / `usage.output_tokens` into [`Usage`], defaulting + /// to zero when the `usage` object is absent. + fn build_response(raw: &Value) -> crate::api::NonStreamingResponse { + let mut parts: Vec = Vec::new(); + if let Some(blocks) = raw.get("content").and_then(|c| c.as_array()) { + for block in blocks { + match block.get("type").and_then(|t| t.as_str()) { + Some("text") => { + if let Some(text) = block.get("text").and_then(|t| t.as_str()) { + parts.push(MessagePart::text(text)); + } + } + Some("tool_use") => { + let id = block.get("id").and_then(|v| v.as_str()).unwrap_or(""); + let name = block.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let input = block.get("input").cloned().unwrap_or(Value::Null); + parts.push(MessagePart::tool_call(id, name, input)); + } + _ => {} + } + } + } + let stop_reason = raw + .get("stop_reason") + .and_then(|r| r.as_str()) + .and_then(StreamStopReason::from_api_str) + .unwrap_or(StreamStopReason::EndTurn); + let usage = extract_usage(raw); + crate::api::NonStreamingResponse { + message: Message::new(Role::Assistant, parts), + stop_reason, + usage, + } + } +} + +/// Extract token [`Usage`] from Anthropic's native `usage` object. +/// +/// Reads `usage.input_tokens` and `usage.output_tokens`. Returns `None` when +/// the `usage` object is absent from the response. Mirrors the extraction the +/// streaming emitter performs in `on_message_delta`. +fn extract_usage(raw: &Value) -> Option { + let usage = raw.get("usage")?; + let input = usage + .get("input_tokens") + .and_then(Value::as_u64) + .and_then(|n| u32::try_from(n).ok()) + .unwrap_or(0); + let output = usage + .get("output_tokens") + .and_then(Value::as_u64) + .and_then(|n| u32::try_from(n).ok()) + .unwrap_or(0); + Some(Usage::new(input, output)) } impl ApiClient for AnthropicClient { @@ -219,7 +285,8 @@ impl ApiClient for AnthropicClient { fn create_message( &self, request: &crate::api::StreamRequest, - ) -> Pin> + Send + '_>> { + ) -> Pin> + Send + '_>> + { self.create_message_with_options(request, crate::structured::RequestOptions::default()) } @@ -270,7 +337,8 @@ impl ApiClient for AnthropicClient { &self, request: &crate::api::StreamRequest, options: crate::structured::RequestOptions, - ) -> Pin> + Send + '_>> { + ) -> Pin> + Send + '_>> + { let system = request.system.clone(); let tools = request.tools.clone(); let model = crate::error::recover_guard(self.model.lock()).clone(); @@ -291,24 +359,11 @@ impl ApiClient for AnthropicClient { Box::pin(async move { let resp = Self::post_messages(&self.http, &url, &self.api_key, &body).await?; let resp = super::read_bounded_body(resp).await?; - serde_json::from_slice::(&resp).map_err(|e| ApiError::http(e.to_string())) + let raw = serde_json::from_slice::(&resp) + .map_err(|e| ApiError::http(e.to_string()))?; + Ok(Self::build_response(&raw)) }) } - - fn extract_structured(&self, raw: &Value) -> Value { - raw.get("content") - .and_then(|c| c.as_array()) - .and_then(|blocks| { - blocks.iter().find_map(|block| { - if block.get("type").and_then(|t| t.as_str()) == Some("tool_use") { - block.get("input").cloned() - } else { - None - } - }) - }) - .unwrap_or_else(|| raw.clone()) - } } /// Builder for [`AnthropicClient`]. @@ -1404,6 +1459,7 @@ mod tests { Role::User, vec![MessagePart::ToolResult { call_id: "call_1".into(), + name: "echo".into(), output: ToolContent::from_string("result text"), is_error: None, }], @@ -1994,36 +2050,205 @@ mod tests { } #[test] - fn extract_structured_from_tool_use_input() { + fn extract_structured_from_tool_call_part() { let client = AnthropicClient::builder() .with_api_key("test") .build() .unwrap(); - let raw = serde_json::json!({ - "content": [{ - "type": "tool_use", - "name": "action", - "input": {"tool": "write", "args": {}} - }] - }); - let value = client.extract_structured(&raw); + let message = Message::new( + Role::Assistant, + vec![MessagePart::tool_call( + "tu_1", + "action", + serde_json::json!({"tool": "write", "args": {}}), + )], + ); + let value = client.extract_structured(&message); assert_eq!(value["tool"], "write"); } #[test] - fn extract_structured_text_only_falls_back_to_raw() { + fn extract_structured_text_only_falls_back_to_string() { let client = AnthropicClient::builder() .with_api_key("test") .build() .unwrap(); + let message = Message::assistant("I cannot do that."); + let value = client.extract_structured(&message); + assert_eq!(value, serde_json::json!("I cannot do that.")); + } + + #[test] + fn build_response_maps_text_block_and_end_turn() { + let raw = serde_json::json!({ + "content": [{"type": "text", "text": "hello there"}], + "stop_reason": "end_turn" + }); + let response = AnthropicClient::build_response(&raw); + assert_eq!(response.message.role, Role::Assistant); + assert_eq!(response.message.text_content(), "hello there"); + assert_eq!(response.stop_reason, StreamStopReason::EndTurn); + } + + #[test] + fn build_response_maps_tool_use_block_and_tool_call() { + let raw = serde_json::json!({ + "content": [{ + "type": "tool_use", + "id": "toolu_1", + "name": "search", + "input": {"q": "rust"} + }], + "stop_reason": "tool_use" + }); + let response = AnthropicClient::build_response(&raw); + assert_eq!(response.message.parts.len(), 1); + match &response.message.parts[0] { + MessagePart::ToolCall { id, name, input } => { + assert_eq!(id, "toolu_1"); + assert_eq!(name, "search"); + assert_eq!(input, &serde_json::json!({"q": "rust"})); + } + other => panic!("expected ToolCall, got {other:?}"), + } + assert_eq!(response.stop_reason, StreamStopReason::ToolCall); + } + + #[test] + fn build_response_preserves_block_order() { + let raw = serde_json::json!({ + "content": [ + {"type": "text", "text": "thinking..."}, + {"type": "tool_use", "id": "t1", "name": "a", "input": {}}, + {"type": "text", "text": "done"} + ], + "stop_reason": "end_turn" + }); + let response = AnthropicClient::build_response(&raw); + assert_eq!(response.message.parts.len(), 3); + assert!(response.message.parts[0].is_text()); + assert!(response.message.parts[1].is_tool_call()); + assert!(response.message.parts[2].is_text()); + } + + #[test] + fn build_response_skips_thinking_blocks() { + let raw = serde_json::json!({ + "content": [ + {"type": "thinking", "thinking": "internal reasoning"}, + {"type": "text", "text": "visible answer"} + ], + "stop_reason": "end_turn" + }); + let response = AnthropicClient::build_response(&raw); + assert_eq!(response.message.parts.len(), 1); + assert_eq!(response.message.text_content(), "visible answer"); + } + + #[test] + fn build_response_maps_max_tokens_stop_reason() { + let raw = serde_json::json!({ + "content": [{"type": "text", "text": "truncated"}], + "stop_reason": "max_tokens" + }); + let response = AnthropicClient::build_response(&raw); + assert_eq!(response.stop_reason, StreamStopReason::MaxTokens); + } + + #[test] + fn build_response_unknown_stop_reason_defaults_to_end_turn() { + let raw = serde_json::json!({ + "content": [{"type": "text", "text": "hi"}], + "stop_reason": "something_new" + }); + let response = AnthropicClient::build_response(&raw); + assert_eq!(response.stop_reason, StreamStopReason::EndTurn); + } + + #[test] + fn build_response_missing_stop_reason_defaults_to_end_turn() { + let raw = serde_json::json!({ + "content": [{"type": "text", "text": "hi"}] + }); + let response = AnthropicClient::build_response(&raw); + assert_eq!(response.stop_reason, StreamStopReason::EndTurn); + } + + #[test] + fn build_response_empty_content_yields_empty_message() { + let raw = serde_json::json!({"content": [], "stop_reason": "end_turn"}); + let response = AnthropicClient::build_response(&raw); + assert!(response.message.parts.is_empty()); + assert_eq!(response.stop_reason, StreamStopReason::EndTurn); + } + + #[test] + fn build_response_extracts_usage() { + let raw = serde_json::json!({ + "content": [{"type": "text", "text": "hi"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 30, "output_tokens": 12} + }); + let response = AnthropicClient::build_response(&raw); + assert_eq!(response.usage.expect("usage").input_tokens, 30); + assert_eq!(response.usage.expect("usage").output_tokens, 12); + } + + #[test] + fn build_response_missing_usage_is_none() { + let raw = serde_json::json!({ + "content": [{"type": "text", "text": "hi"}], + "stop_reason": "end_turn" + }); + let response = AnthropicClient::build_response(&raw); + assert!(response.usage.is_none()); + } + + #[test] + fn build_response_missing_content_yields_empty_message() { + let raw = serde_json::json!({"stop_reason": "end_turn"}); + let response = AnthropicClient::build_response(&raw); + assert!(response.message.parts.is_empty()); + assert_eq!(response.stop_reason, StreamStopReason::EndTurn); + } + + #[test] + fn build_response_text_block_missing_text_is_skipped() { + let raw = serde_json::json!({ + "content": [ + {"type": "text"}, + {"type": "text", "text": "valid"} + ], + "stop_reason": "end_turn" + }); + let response = AnthropicClient::build_response(&raw); + assert_eq!(response.message.parts.len(), 1); + assert_eq!(response.message.text_content(), "valid"); + } + + #[test] + fn build_response_tool_use_missing_input_defaults_to_null() { + let raw = serde_json::json!({ + "content": [{"type": "tool_use", "id": "tu_1", "name": "search"}], + "stop_reason": "tool_use" + }); + let response = AnthropicClient::build_response(&raw); + match &response.message.parts[0] { + MessagePart::ToolCall { input, .. } => { + assert_eq!(input, &Value::Null); + } + other => panic!("expected ToolCall, got {other:?}"), + } + } + + #[test] + fn build_response_maps_stop_sequence_reason() { let raw = serde_json::json!({ - "id": "msg_1", - "model": "claude-3", - "content": [{"type": "text", "text": "I cannot do that."}] + "content": [{"type": "text", "text": "stopped"}], + "stop_reason": "stop_sequence" }); - let value = client.extract_structured(&raw); - // No tool_use block → returns the raw envelope; T::from_value fails. - assert_eq!(value["id"], "msg_1"); + let response = AnthropicClient::build_response(&raw); + assert_eq!(response.stop_reason, StreamStopReason::StopSequence); } #[test] diff --git a/src/provider/gemini.rs b/src/provider/gemini.rs index b00bd48..c756698 100644 --- a/src/provider/gemini.rs +++ b/src/provider/gemini.rs @@ -166,6 +166,56 @@ impl GeminiClient { format!("{}/models/{}:generateContent", self.base_url, model) } + /// Build a typed [`NonStreamingResponse`] from Gemini's native JSON. + /// + /// Reads `candidates[0].content.parts` into [`MessagePart`]s: each `text` + /// field becomes a [`MessagePart::Text`] part and each `functionCall` + /// becomes a [`MessagePart::ToolCall`] (Gemini function calls carry no + /// caller-side id, so the id is left empty — matching the streaming path). + /// A single part may hold both `text` and `functionCall`, in which case it + /// yields two parts. Maps `candidates[0].finishReason` to a + /// [`StreamStopReason`] using the same mapping the streaming emitter + /// applies: `"MAX_TOKENS"` → `MaxTokens`, anything else (including the + /// `"STOP"` default) → `EndTurn`. Reads `usageMetadata.promptTokenCount` + /// and `candidatesTokenCount` (plus `thoughtsTokenCount`) into [`Usage`], + /// defaulting to zero when the object is absent. + fn build_response(raw: &Value) -> crate::api::NonStreamingResponse { + let mut parts: Vec = Vec::new(); + if let Some(content_parts) = raw + .pointer("/candidates/0/content/parts") + .and_then(|p| p.as_array()) + { + for part in content_parts { + if let Some(text) = part.get("text").and_then(|t| t.as_str()) { + parts.push(MessagePart::text(text)); + } + if let Some(fc) = part.get("functionCall") { + let id = fc.get("id").and_then(|v| v.as_str()).unwrap_or(""); + let name = fc.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let input = fc + .get("args") + .cloned() + .unwrap_or_else(|| serde_json::json!({})); + parts.push(MessagePart::tool_call(id, name, input)); + } + } + } + let reason = raw + .pointer("/candidates/0/finishReason") + .and_then(|r| r.as_str()) + .unwrap_or("STOP"); + let stop_reason = match reason { + "MAX_TOKENS" => StreamStopReason::MaxTokens, + _ => StreamStopReason::EndTurn, + }; + let usage = extract_usage(raw); + crate::api::NonStreamingResponse { + message: Message::new(Role::Assistant, parts), + stop_reason, + usage, + } + } + /// Send a POST request and return the raw response. /// /// Shared by both [`ApiClient::stream_messages`] and @@ -203,6 +253,33 @@ impl GeminiClient { } } +/// Extract token [`Usage`] from Gemini's native `usageMetadata` object. +/// +/// Reads `usageMetadata.promptTokenCount` for input tokens and sums +/// `candidatesTokenCount` plus `thoughtsTokenCount` for output. Returns `None` +/// when the `usageMetadata` object is absent from the response. This mirrors +/// the streaming path, which reads the same fields from the finish chunk to +/// populate the `MessageDelta` usage. +fn extract_usage(raw: &Value) -> Option { + let usage = raw.pointer("/usageMetadata")?; + let input = usage + .get("promptTokenCount") + .and_then(Value::as_u64) + .and_then(|n| u32::try_from(n).ok()) + .unwrap_or(0); + let output = usage + .get("candidatesTokenCount") + .and_then(Value::as_u64) + .and_then(|n| u32::try_from(n).ok()) + .unwrap_or(0); + let thoughts = usage + .get("thoughtsTokenCount") + .and_then(Value::as_u64) + .and_then(|n| u32::try_from(n).ok()) + .unwrap_or(0); + Some(Usage::new(input, output.saturating_add(thoughts))) +} + impl ApiClient for GeminiClient { fn model(&self) -> String { crate::error::recover_guard(self.model.lock()).clone() @@ -259,7 +336,8 @@ impl ApiClient for GeminiClient { fn create_message( &self, request: &crate::api::StreamRequest, - ) -> Pin> + Send + '_>> { + ) -> Pin> + Send + '_>> + { let system = request.system.clone(); let tools = request.tools.clone(); let body = build_request_body( @@ -275,7 +353,9 @@ impl ApiClient for GeminiClient { Box::pin(async move { let resp = Self::post_content(&self.http, &url, &self.api_key, &body).await?; let resp = super::read_bounded_body(resp).await?; - serde_json::from_slice::(&resp).map_err(|e| ApiError::http(e.to_string())) + let raw = serde_json::from_slice::(&resp) + .map_err(|e| ApiError::http(e.to_string()))?; + Ok(Self::build_response(&raw)) }) } @@ -321,7 +401,8 @@ impl ApiClient for GeminiClient { &self, request: &crate::api::StreamRequest, options: crate::structured::RequestOptions, - ) -> Pin> + Send + '_>> { + ) -> Pin> + Send + '_>> + { let system = request.system.clone(); let tools = request.tools.clone(); let response_format = options.response_format.as_ref(); @@ -337,20 +418,11 @@ impl ApiClient for GeminiClient { Box::pin(async move { let resp = Self::post_content(&self.http, &url, &self.api_key, &body).await?; let resp = super::read_bounded_body(resp).await?; - serde_json::from_slice::(&resp).map_err(|e| ApiError::http(e.to_string())) + let raw = serde_json::from_slice::(&resp) + .map_err(|e| ApiError::http(e.to_string()))?; + Ok(Self::build_response(&raw)) }) } - - fn extract_structured(&self, raw: &Value) -> Value { - let Some(text) = raw - .pointer("/candidates/0/content/parts/0/text") - .and_then(serde_json::Value::as_str) - else { - return raw.clone(); - }; - crate::structured::parse_json_lenient(text) - .unwrap_or_else(|| Value::String(text.to_string())) - } } /// Builder for [`GeminiClient`]. @@ -627,20 +699,32 @@ fn convert_message(m: &Message) -> Value { fn convert_part(p: &MessagePart) -> Option { match p { MessagePart::Text { text } => Some(serde_json::json!({"text": text})), - MessagePart::ToolCall { name, input, .. } => Some(serde_json::json!({ - "functionCall": { - "name": name, - "args": input, + MessagePart::ToolCall { id, name, input } => { + let mut fc = serde_json::Map::new(); + fc.insert("name".to_string(), serde_json::Value::String(name.clone())); + fc.insert("args".to_string(), input.clone()); + if !id.is_empty() { + fc.insert("id".to_string(), serde_json::Value::String(id.clone())); } - })), + Some(serde_json::json!({"functionCall": fc})) + } MessagePart::ToolResult { - call_id, output, .. - } => Some(serde_json::json!({ - "functionResponse": { - "name": call_id, - "response": {"result": output.to_string()}, + call_id, + name, + output, + .. + } => { + let mut fr = serde_json::Map::new(); + fr.insert("name".to_string(), serde_json::Value::String(name.clone())); + if !call_id.is_empty() { + fr.insert("id".to_string(), serde_json::Value::String(call_id.clone())); } - })), + fr.insert( + "response".to_string(), + serde_json::json!({"result": output.to_string()}), + ); + Some(serde_json::json!({"functionResponse": fr})) + } MessagePart::Image { .. } => None, } } @@ -893,6 +977,11 @@ impl StreamEmitter { let Some(func_call) = part.get("functionCall") else { return; }; + let id = func_call + .pointer("/id") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); let name = func_call .pointer("/name") .and_then(Value::as_str) @@ -916,7 +1005,7 @@ impl StreamEmitter { self.push(StreamEvent::PartStart(PartStart { index: idx, part: Some(MessagePart::ToolCall { - id: String::new(), + id, name, input: args, }), @@ -1113,6 +1202,7 @@ mod tests { Role::User, vec![MessagePart::ToolResult { call_id: "call_1".into(), + name: "echo".into(), output: ToolContent::from_string("result text"), is_error: None, }], @@ -1120,13 +1210,70 @@ mod tests { let body = build_request_body(&msgs, None, None, None, &ToolConstraint::None, false); let parts = body["contents"][0]["parts"].as_array().unwrap(); - assert_eq!(parts[0]["functionResponse"]["name"], "call_1"); + assert_eq!(parts[0]["functionResponse"]["name"], "echo"); + assert_eq!(parts[0]["functionResponse"]["id"], "call_1"); assert_eq!( parts[0]["functionResponse"]["response"]["result"], "result text" ); } + #[test] + fn request_body_function_response_includes_name_and_id() { + let msgs = vec![Message::new( + Role::User, + vec![MessagePart::ToolResult { + call_id: "fc_99".into(), + name: "search".into(), + output: ToolContent::from_string("results here"), + is_error: None, + }], + )]; + let body = build_request_body(&msgs, None, None, None, &ToolConstraint::None, false); + + let fr = &body["contents"][0]["parts"][0]["functionResponse"]; + assert_eq!(fr["name"], "search"); + assert_eq!(fr["id"], "fc_99"); + assert_eq!(fr["response"]["result"], "results here"); + } + + #[test] + fn request_body_function_response_omits_id_when_empty() { + let msgs = vec![Message::new( + Role::User, + vec![MessagePart::ToolResult { + call_id: String::new(), + name: "search".into(), + output: ToolContent::from_string("ok"), + is_error: None, + }], + )]; + let body = build_request_body(&msgs, None, None, None, &ToolConstraint::None, false); + + let fr = &body["contents"][0]["parts"][0]["functionResponse"]; + assert_eq!(fr["name"], "search"); + assert!( + fr.get("id").is_none(), + "id should be omitted when call_id is empty" + ); + } + + #[test] + fn request_body_function_call_omits_id_when_empty() { + let msgs = vec![Message::new( + Role::Assistant, + vec![MessagePart::tool_call("", "search", serde_json::json!({}))], + )]; + let body = build_request_body(&msgs, None, None, None, &ToolConstraint::None, false); + + let fc = &body["contents"][0]["parts"][0]["functionCall"]; + assert_eq!(fc["name"], "search"); + assert!( + fc.get("id").is_none(), + "id should be omitted when tool-call id is empty" + ); + } + #[test] fn request_body_includes_tools() { let msgs = vec![Message::user("hi")]; @@ -1359,6 +1506,51 @@ mod tests { ); } + #[test] + fn emitter_function_call_includes_id() { + let mut em = StreamEmitter::default(); + em.started = true; + em.process_chunk(&serde_json::json!({ + "candidates": [{"content": {"parts": [{"functionCall": {"id": "fc_7", "name": "search", "args": {"q": "rust"}}}]}}] + })); + let events = em.drain(); + let start = events + .iter() + .find_map(|e| match e { + StreamEvent::PartStart(ps) => Some(ps), + _ => None, + }) + .expect("PartStart"); + match &start.part { + Some(MessagePart::ToolCall { id, name, .. }) => { + assert_eq!(id, "fc_7"); + assert_eq!(name, "search"); + } + other => panic!("expected ToolCall, got {other:?}"), + } + } + + #[test] + fn emitter_function_call_without_id_defaults_to_empty() { + let mut em = StreamEmitter::default(); + em.started = true; + em.process_chunk(&serde_json::json!({ + "candidates": [{"content": {"parts": [{"functionCall": {"name": "search", "args": {}}}]}}] + })); + let events = em.drain(); + let start = events + .iter() + .find_map(|e| match e { + StreamEvent::PartStart(ps) => Some(ps), + _ => None, + }) + .expect("PartStart"); + match &start.part { + Some(MessagePart::ToolCall { id, .. }) => assert_eq!(id, ""), + other => panic!("expected ToolCall, got {other:?}"), + } + } + #[test] fn emitter_thought_part_routes_to_thinking_variant() { let mut em = StreamEmitter::default(); @@ -2074,40 +2266,279 @@ mod tests { } #[test] - fn extract_structured_from_text_field() { + fn extract_structured_from_text_part() { + let client = GeminiClient::builder() + .with_api_key("test") + .build() + .unwrap(); + let message = Message::assistant(r#"{"tool": "write", "args": {}}"#); + let value = client.extract_structured(&message); + assert_eq!(value["tool"], "write"); + } + + #[test] + fn extract_structured_prose_falls_back_to_string() { let client = GeminiClient::builder() .with_api_key("test") .build() .unwrap(); + let message = Message::assistant("I cannot produce that."); + let value = client.extract_structured(&message); + assert_eq!(value, serde_json::json!("I cannot produce that.")); + } + + #[test] + fn build_response_maps_text_part_and_stop() { + let raw = serde_json::json!({ + "candidates": [{ + "content": {"parts": [{"text": "hello gemini"}]}, + "finishReason": "STOP" + }] + }); + let response = GeminiClient::build_response(&raw); + assert_eq!(response.message.role, Role::Assistant); + assert_eq!(response.message.text_content(), "hello gemini"); + assert_eq!(response.stop_reason, StreamStopReason::EndTurn); + } + + #[test] + fn build_response_maps_function_call_part() { let raw = serde_json::json!({ "candidates": [{ "content": { "parts": [{ - "text": r#"{"tool": "write", "args": {}}"# + "functionCall": {"name": "search", "args": {"q": "rust"}} }] - } + }, + "finishReason": "STOP" }] }); - let value = client.extract_structured(&raw); - assert_eq!(value["tool"], "write"); + let response = GeminiClient::build_response(&raw); + assert_eq!(response.message.parts.len(), 1); + match &response.message.parts[0] { + MessagePart::ToolCall { id, name, input } => { + assert_eq!(id, ""); + assert_eq!(name, "search"); + assert_eq!(input, &serde_json::json!({"q": "rust"})); + } + other => panic!("expected ToolCall, got {other:?}"), + } } #[test] - fn extract_structured_prose_falls_back_to_raw() { - let client = GeminiClient::builder() - .with_api_key("test") - .build() - .unwrap(); + fn build_response_parses_function_call_id() { + let raw = serde_json::json!({ + "candidates": [{ + "content": {"parts": [{"functionCall": {"id": "fc_42", "name": "search", "args": {}}}]}, + "finishReason": "STOP" + }] + }); + let response = GeminiClient::build_response(&raw); + match &response.message.parts[0] { + MessagePart::ToolCall { id, name, .. } => { + assert_eq!(id, "fc_42"); + assert_eq!(name, "search"); + } + other => panic!("expected ToolCall, got {other:?}"), + } + } + + #[test] + fn build_response_function_call_without_id_defaults_to_empty() { + let raw = serde_json::json!({ + "candidates": [{ + "content": {"parts": [{"functionCall": {"name": "search", "args": {}}}]}, + "finishReason": "STOP" + }] + }); + let response = GeminiClient::build_response(&raw); + match &response.message.parts[0] { + MessagePart::ToolCall { id, .. } => assert_eq!(id, ""), + other => panic!("expected ToolCall, got {other:?}"), + } + } + + #[test] + fn build_response_handles_text_and_function_call_in_one_part() { let raw = serde_json::json!({ "candidates": [{ "content": { - "parts": [{"text": "I cannot produce that."}] - } + "parts": [{ + "text": "Let me search", + "functionCall": {"name": "search", "args": {}} + }] + }, + "finishReason": "STOP" }] }); - let value = client.extract_structured(&raw); - // Prose text not parseable as JSON → falls back to the string value. - assert_eq!(value, serde_json::json!("I cannot produce that.")); + let response = GeminiClient::build_response(&raw); + assert_eq!( + response.message.parts.len(), + 2, + "text + functionCall in one part should yield two MessageParts" + ); + assert!(response.message.parts[0].is_text()); + assert!(response.message.parts[1].is_tool_call()); + } + + #[test] + fn build_response_maps_max_tokens_finish_reason() { + let raw = serde_json::json!({ + "candidates": [{ + "content": {"parts": [{"text": "truncated"}]}, + "finishReason": "MAX_TOKENS" + }] + }); + let response = GeminiClient::build_response(&raw); + assert_eq!(response.stop_reason, StreamStopReason::MaxTokens); + } + + #[test] + fn build_response_safety_finish_reason_maps_to_end_turn() { + let raw = serde_json::json!({ + "candidates": [{ + "content": {"parts": [{"text": "blocked"}]}, + "finishReason": "SAFETY" + }] + }); + let response = GeminiClient::build_response(&raw); + assert_eq!(response.stop_reason, StreamStopReason::EndTurn); + } + + #[test] + fn build_response_missing_finish_reason_defaults_to_end_turn() { + let raw = serde_json::json!({ + "candidates": [{ + "content": {"parts": [{"text": "hi"}]} + }] + }); + let response = GeminiClient::build_response(&raw); + assert_eq!(response.stop_reason, StreamStopReason::EndTurn); + } + + #[test] + fn build_response_missing_candidates_yields_empty_message() { + let raw = serde_json::json!({}); + let response = GeminiClient::build_response(&raw); + assert!(response.message.parts.is_empty()); + assert_eq!(response.stop_reason, StreamStopReason::EndTurn); + } + + #[test] + fn build_response_extracts_usage() { + let raw = serde_json::json!({ + "candidates": [{ + "content": {"parts": [{"text": "hi"}]}, + "finishReason": "STOP" + }], + "usageMetadata": { + "promptTokenCount": 25, + "candidatesTokenCount": 10, + "thoughtsTokenCount": 5 + } + }); + let response = GeminiClient::build_response(&raw); + assert_eq!(response.usage.expect("usage").input_tokens, 25); + assert_eq!(response.usage.expect("usage").output_tokens, 15); + } + + #[test] + fn build_response_usage_without_thoughts() { + let raw = serde_json::json!({ + "candidates": [{ + "content": {"parts": [{"text": "hi"}]}, + "finishReason": "STOP" + }], + "usageMetadata": { + "promptTokenCount": 8, + "candidatesTokenCount": 4 + } + }); + let response = GeminiClient::build_response(&raw); + assert_eq!(response.usage.expect("usage").input_tokens, 8); + assert_eq!(response.usage.expect("usage").output_tokens, 4); + } + + #[test] + fn build_response_missing_usage_is_none() { + let raw = serde_json::json!({ + "candidates": [{ + "content": {"parts": [{"text": "hi"}]}, + "finishReason": "STOP" + }] + }); + let response = GeminiClient::build_response(&raw); + assert!(response.usage.is_none()); + } + + #[test] + fn build_response_multiple_function_calls_across_parts() { + let raw = serde_json::json!({ + "candidates": [{ + "content": {"parts": [ + {"functionCall": {"name": "first", "args": {}}}, + {"functionCall": {"name": "second", "args": {"n": 2}}} + ]}, + "finishReason": "STOP" + }] + }); + let response = GeminiClient::build_response(&raw); + assert_eq!(response.message.parts.len(), 2); + match &response.message.parts[0] { + MessagePart::ToolCall { name, .. } => assert_eq!(name, "first"), + other => panic!("expected ToolCall, got {other:?}"), + } + match &response.message.parts[1] { + MessagePart::ToolCall { name, .. } => assert_eq!(name, "second"), + other => panic!("expected ToolCall, got {other:?}"), + } + } + + #[test] + fn build_response_function_call_missing_args_defaults_to_empty_object() { + let raw = serde_json::json!({ + "candidates": [{ + "content": {"parts": [{"functionCall": {"name": "search"}}]}, + "finishReason": "STOP" + }] + }); + let response = GeminiClient::build_response(&raw); + match &response.message.parts[0] { + MessagePart::ToolCall { input, .. } => { + assert_eq!(input, &serde_json::json!({})); + } + other => panic!("expected ToolCall, got {other:?}"), + } + } + + #[test] + fn build_response_multiple_text_parts_preserve_order() { + let raw = serde_json::json!({ + "candidates": [{ + "content": {"parts": [ + {"text": "hello"}, + {"text": " world"} + ]}, + "finishReason": "STOP" + }] + }); + let response = GeminiClient::build_response(&raw); + assert_eq!(response.message.parts.len(), 2); + assert_eq!(response.message.text_content(), "hello world"); + } + + #[test] + fn build_response_partial_usage_with_only_input() { + let raw = serde_json::json!({ + "candidates": [{ + "content": {"parts": [{"text": "hi"}]}, + "finishReason": "STOP" + }], + "usageMetadata": {"promptTokenCount": 99} + }); + let response = GeminiClient::build_response(&raw); + assert_eq!(response.usage.expect("usage").input_tokens, 99); + assert_eq!(response.usage.expect("usage").output_tokens, 0); } #[test] diff --git a/src/provider/openai.rs b/src/provider/openai.rs index 0c1f567..65cdeff 100644 --- a/src/provider/openai.rs +++ b/src/provider/openai.rs @@ -33,7 +33,7 @@ use crate::api::error::ApiError; use crate::message::{Message, MessagePart, Role}; use crate::stream::{ DeltaPart, IndexedDelta, MessageDelta, MessageDeltaPayload, MessageMetadata, MessageStart, - PartStart, StreamEvent, StreamStopReason, + PartStart, StreamEvent, StreamStopReason, Usage, }; use crate::structured::ToolConstraint; use crate::structured::tighten_json_schema; @@ -81,6 +81,13 @@ pub struct OpenAiClient { /// [`FallbackManager`](crate::fallback::FallbackManager) trips to a /// fallback model. model: std::sync::Mutex, + + /// Whether to request `stream_options.include_usage` on streaming requests. + /// + /// Defaults to `true` (real OpenAI supports it). Disabled for + /// OpenAI-compatible servers that reject the parameter via + /// [`OpenAiClientBuilder::with_stream_usage`]. + stream_usage: bool, } impl OpenAiClient { @@ -152,6 +159,63 @@ impl OpenAiClient { format!("{}/chat/completions", self.base_url) } + /// Build a typed [`NonStreamingResponse`] from OpenAI's native JSON. + /// + /// Reads `choices[0].message` into [`MessagePart`]s: the `content` + /// string becomes a [`MessagePart::Text`] part (skipped when `null`), + /// and each entry in `tool_calls` becomes a [`MessagePart::ToolCall`] + /// with its `function.arguments` JSON-string parsed into a [`Value`]. + /// Maps `choices[0].finish_reason` to a [`StreamStopReason`] using the + /// same mapping the streaming emitter applies (`"tool_calls"` → + /// `ToolCall`, `"length"` → `MaxTokens`, anything else via + /// [`StreamStopReason::from_api_str`], defaulting to `EndTurn`). Reads + /// `usage.prompt_tokens` / `usage.completion_tokens` into [`Usage`], + /// defaulting to zero when the `usage` object is absent. + fn build_response(raw: &Value) -> crate::api::NonStreamingResponse { + let choice = raw.get("choices").and_then(|c| c.get(0)); + let message = choice.and_then(|c| c.get("message")); + let mut parts: Vec = Vec::new(); + if let Some(msg) = message { + if let Some(text) = msg.get("content").and_then(|t| t.as_str()) { + parts.push(MessagePart::text(text)); + } + if let Some(tool_calls) = msg.get("tool_calls").and_then(|t| t.as_array()) { + for tc in tool_calls { + let id = tc.get("id").and_then(|v| v.as_str()).unwrap_or(""); + let function = tc.get("function"); + let name = function + .and_then(|f| f.get("name")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + let input = function + .and_then(|f| f.get("arguments")) + .and_then(|a| a.as_str()) + .and_then(|s| serde_json::from_str::(s).ok()) + .unwrap_or_else(|| serde_json::json!({})); + parts.push(MessagePart::tool_call(id, name, input)); + } + } + } + let reason = choice + .and_then(|c| c.get("finish_reason")) + .and_then(|r| r.as_str()) + .unwrap_or("stop"); + let stop_reason = match reason { + "tool_calls" => StreamStopReason::ToolCall, + "length" => StreamStopReason::MaxTokens, + other => StreamStopReason::from_api_str(other).unwrap_or(StreamStopReason::EndTurn), + }; + let usage = raw + .get("usage") + .and_then(|u| OpenAiUsage::deserialize(u).ok()) + .map(|u| Usage::from(&u)); + crate::api::NonStreamingResponse { + message: Message::new(Role::Assistant, parts), + stop_reason, + usage, + } + } + /// Send a POST request to the chat-completions endpoint. /// /// Shared by both [`ApiClient::stream_messages`] and @@ -222,7 +286,8 @@ impl ApiClient for OpenAiClient { tools.as_deref(), None, &ToolConstraint::None, - ); + ) + .with_stream_usage(self.stream_usage); let url = self.completions_url(); let api_key = self.api_key.clone(); let http = self.http.clone(); @@ -251,7 +316,8 @@ impl ApiClient for OpenAiClient { fn create_message( &self, request: &crate::api::StreamRequest, - ) -> Pin> + Send + '_>> { + ) -> Pin> + Send + '_>> + { let system = request.system.clone(); let tools = request.tools.clone(); let model = crate::error::recover_guard(self.model.lock()).clone(); @@ -270,7 +336,9 @@ impl ApiClient for OpenAiClient { Self::post_completions(&self.http, &url, &self.api_key, &body.to_json(false)) .await?; let resp = super::read_bounded_body(resp).await?; - serde_json::from_slice::(&resp).map_err(|e| ApiError::http(e.to_string())) + let raw = serde_json::from_slice::(&resp) + .map_err(|e| ApiError::http(e.to_string()))?; + Ok(Self::build_response(&raw)) }) } @@ -290,7 +358,8 @@ impl ApiClient for OpenAiClient { tools.as_deref(), rf, &options.tool_constraint, - ); + ) + .with_stream_usage(self.stream_usage); let url = self.completions_url(); let api_key = self.api_key.clone(); let http = self.http.clone(); @@ -320,7 +389,8 @@ impl ApiClient for OpenAiClient { &self, request: &crate::api::StreamRequest, options: crate::structured::RequestOptions, - ) -> Pin> + Send + '_>> { + ) -> Pin> + Send + '_>> + { let system = request.system.clone(); let tools = request.tools.clone(); let model = crate::error::recover_guard(self.model.lock()).clone(); @@ -340,26 +410,11 @@ impl ApiClient for OpenAiClient { Self::post_completions(&self.http, &url, &self.api_key, &body.to_json(false)) .await?; let resp = super::read_bounded_body(resp).await?; - serde_json::from_slice::(&resp).map_err(|e| ApiError::http(e.to_string())) + let raw = serde_json::from_slice::(&resp) + .map_err(|e| ApiError::http(e.to_string()))?; + Ok(Self::build_response(&raw)) }) } - - fn extract_structured(&self, raw: &Value) -> Value { - let Some(content) = raw - .get("choices") - .and_then(|c| c.get(0)) - .and_then(|c| c.get("message")) - .and_then(|m| m.get("content")) - else { - return raw.clone(); - }; - if let Some(text) = content.as_str() { - crate::structured::parse_json_lenient(text) - .unwrap_or_else(|| Value::String(text.to_string())) - } else { - content.clone() - } - } } /// Builder for [`OpenAiClient`]. @@ -391,6 +446,13 @@ pub struct OpenAiClientBuilder { /// internally-built `reqwest::Client`, or an externally-supplied client /// injected via [`with_http_client`](Self::with_http_client). http: super::HttpClientConfig, + + /// Whether to request `stream_options.include_usage` on streaming requests. + /// + /// Defaults to `true`. Disable for OpenAI-compatible servers that reject + /// the parameter (older Ollama, some self-hosted deployments). Read by + /// [`build`](Self::build) and stored on [`OpenAiClient`]. + stream_usage: bool, } impl Default for OpenAiClientBuilder { @@ -400,6 +462,7 @@ impl Default for OpenAiClientBuilder { base_url: DEFAULT_BASE_URL.into(), model: DEFAULT_MODEL.into(), http: super::HttpClientConfig::default(), + stream_usage: true, } } } @@ -469,6 +532,21 @@ impl OpenAiClientBuilder { self } + /// Control whether streaming requests include `stream_options.include_usage`. + /// + /// Defaults to `true` — real OpenAI, `DeepSeek`, and Grok support it and + /// send a final usage chunk. Pass `false` for OpenAI-compatible servers + /// that reject the parameter with a validation error (older Ollama, some + /// self-hosted vLLM/LM Studio deployments). When disabled, streamed turns + /// report `usage: None` instead of real token counts. + /// + /// Ignored on non-streaming requests. + #[must_use] + pub fn with_stream_usage(mut self, enabled: bool) -> Self { + self.stream_usage = enabled; + self + } + /// Set the maximum idle connections kept alive per host. /// /// Defaults to reqwest's built-in default (unlimited). Ignored when a @@ -515,6 +593,7 @@ impl OpenAiClientBuilder { api_key, base_url: self.base_url, model: std::sync::Mutex::new(self.model), + stream_usage: self.stream_usage, }) } } @@ -557,6 +636,14 @@ struct RequestBody { /// and no `response_format` was set. Stored as a string so the body is /// serializable without re-borrowing the trait object. guided_json: Option, + + /// Whether to request `stream_options.include_usage` when streaming. + /// + /// Defaults to `true` (real OpenAI supports it). Disabled for providers + /// that reject the parameter (older Ollama, some self-hosted servers) + /// via [`with_stream_usage`](Self::with_stream_usage). Ignored on + /// non-streaming requests. + stream_usage: bool, } impl RequestBody { @@ -627,15 +714,30 @@ impl RequestBody { tools, response_format: rf, guided_json, + stream_usage: true, } } + /// Control whether streaming requests include `stream_options.include_usage`. + /// + /// Defaults to `true` after [`build`](Self::build). Pass `false` for + /// OpenAI-compatible servers that reject the parameter (older Ollama, some + /// self-hosted deployments). The flag is read by [`to_json`](Self::to_json) + /// and ignored on non-streaming requests. + #[must_use] + fn with_stream_usage(mut self, enabled: bool) -> Self { + self.stream_usage = enabled; + self + } + /// Serialize to a [`serde_json::Value`] for the HTTP request body. /// /// Emits `model`, `messages`, `stream` (toggled by the parameter), - /// and `tools`. When `response_format` is set, appends the - /// `response_format` key; otherwise omits it entirely (not `null`). - /// When a grammar was captured, appends `guided_json`. + /// and `tools`. When streaming and [`stream_usage`](Self::stream_usage) + /// is enabled, sets `stream_options.include_usage` so the server appends a + /// final usage chunk. When `response_format` is set, appends the + /// `response_format` key; otherwise omits it entirely (not `null`). When a + /// grammar was captured, appends `guided_json`. fn to_json(&self, stream: bool) -> Value { let mut body = serde_json::json!({ "model": self.model, @@ -643,6 +745,12 @@ impl RequestBody { "stream": stream, }); if let Some(obj) = body.as_object_mut() { + if stream && self.stream_usage { + obj.insert( + "stream_options".to_string(), + serde_json::json!({"include_usage": true}), + ); + } if let Some(tools) = &self.tools { obj.insert("tools".to_string(), Value::Array(tools.clone())); } @@ -825,8 +933,10 @@ impl SseReader { /// /// Each `data:` line in a streamed Chat Completions response deserializes /// into one of these. The chunk carries the message identity, the model -/// that produced it, and a list of [`OpenAiChoice`] deltas that the -/// [`StreamEmitter`] assembles into [`StreamEvent`]s. +/// that produced it, a list of [`OpenAiChoice`] deltas that the +/// [`StreamEmitter`] assembles into [`StreamEvent`]s, and — on the final +/// chunk when `stream_options.include_usage` is set — the cumulative +/// [`OpenAiUsage`] for the entire request. #[derive(Deserialize)] struct OpenAiChunk { /// Server-assigned identifier for the overall completion. @@ -847,8 +957,20 @@ struct OpenAiChunk { /// /// In practice OpenAI streams a single choice (`n=1`), so this /// vector usually holds exactly one [`OpenAiChoice`] carrying the - /// incremental [`OpenAiDelta`] for this chunk. + /// incremental [`OpenAiDelta`] for this chunk. When + /// `stream_options.include_usage` is set, the final chunk carries an + /// empty `choices` array and the cumulative [`OpenAiUsage`] in + /// [`usage`](Self::usage). choices: Vec, + + /// Cumulative token usage, present only on the final chunk. + /// + /// Populated when the request sets `stream_options.include_usage`; + /// `None` on every preceding chunk and on all chunks when the option + /// is not set. The [`StreamEmitter`] stores this and includes it in + /// the [`MessageDelta`](StreamEvent::MessageDelta) event. + #[serde(default)] + usage: Option, } impl OpenAiChunk { @@ -889,6 +1011,35 @@ struct OpenAiChoice { finish_reason: Option, } +/// Token usage object carried by OpenAI's final streaming chunk. +/// +/// Mirrors the `usage` field that appears on the last chunk when the request +/// sets `stream_options.include_usage`. Deserialized into a [`Usage`] so the +/// emitter can include it in the terminal [`MessageDelta`](StreamEvent::MessageDelta). +/// Both fields default to zero via `#[serde(default)]` so a partial usage +/// object from a non-conforming provider (e.g. one that omits +/// `completion_tokens`) does not fail deserialization and silently drop the +/// entire chunk. +#[derive(Deserialize)] +struct OpenAiUsage { + /// Number of tokens in the input prompt. + #[serde(default)] + prompt_tokens: u64, + + /// Number of tokens in the output completion. + #[serde(default)] + completion_tokens: u64, +} + +impl From<&OpenAiUsage> for Usage { + fn from(u: &OpenAiUsage) -> Self { + Usage::new( + u32::try_from(u.prompt_tokens).unwrap_or(0), + u32::try_from(u.completion_tokens).unwrap_or(0), + ) + } +} + /// Incremental content delivered by one chunk. /// /// Mirrors the `delta` object in OpenAI's streaming protocol. Every @@ -1046,6 +1197,26 @@ struct StreamEmitter { /// [`StreamEvent::MessageStop`] after the stream already terminated. finished: bool, + /// The stop reason captured by [`process_finish`](Self::process_finish), + /// deferred until the usage chunk arrives or [`finish`](Self::finish) + /// flushes it. + /// + /// OpenAI streams usage on a separate final chunk *after* the + /// `finish_reason` chunk (when `stream_options.include_usage` is set). + /// Rather than emit a `MessageDelta` immediately on `finish_reason` and + /// lose the usage, the emitter stores the stop reason here and emits the + /// `MessageDelta` once usage is known — either from the usage chunk or + /// when [`finish`](Self::finish) flushes pending state at stream end. + pending_stop_reason: Option, + + /// Token usage captured from the final usage chunk, if the request + /// set `stream_options.include_usage`. + /// + /// `None` until the usage chunk arrives. When `finish` flushes the + /// deferred `MessageDelta`, this is converted to `Some(Usage)` (or left + /// as `None` if the provider never sent usage). + pending_usage: Option, + /// Buffered [`StreamEvent`]s waiting to be yielded to the consumer. /// /// All event-producing methods push onto this queue via @@ -1076,16 +1247,21 @@ impl StreamEmitter { })); } - let Some(choice) = chunk.choices.first() else { - return; - }; + if let Some(usage) = &chunk.usage { + self.pending_usage = Some(Usage::from(usage)); + } - if let Some(delta) = &choice.delta { - self.process_delta(delta); + if let Some(choice) = chunk.choices.first() { + if let Some(delta) = &choice.delta { + self.process_delta(delta); + } + if let Some(reason) = &choice.finish_reason { + self.process_finish(reason); + } } - if let Some(reason) = &choice.finish_reason { - self.process_finish(reason); + if chunk.usage.is_some() { + self.flush_message_delta(); } } @@ -1190,13 +1366,17 @@ impl StreamEmitter { } } - /// Handle a finish reason, emitting the appropriate stop events. + /// Handle a finish reason, closing open parts and deferring the + /// `MessageDelta`. /// /// Closes any open text parts and tool-call parts with - /// [`PartStop`](StreamEvent::PartStop), then emits a - /// [`MessageDelta`](StreamEvent::MessageDelta) carrying the mapped - /// [`StreamStopReason`]. Maps `"tool_calls"` → - /// [`ToolCall`](StreamStopReason::ToolCall), `"length"` → + /// [`PartStop`](StreamEvent::PartStop), then stores the mapped + /// [`StreamStopReason`] as pending. The [`MessageDelta`] is not emitted + /// here — it is deferred until the usage chunk arrives (when + /// `stream_options.include_usage` is set) or flushed by + /// [`finish`](Self::finish) at stream end, so the `MessageDelta` carries + /// both the stop reason and the usage in one event. Maps `"tool_calls"` + /// → [`ToolCall`](StreamStopReason::ToolCall), `"length"` → /// [`MaxTokens`](StreamStopReason::MaxTokens), and anything else via /// [`StreamStopReason::from_api_str`]. No-ops if already finished. fn process_finish(&mut self, reason: &str) { @@ -1222,21 +1402,37 @@ impl StreamEmitter { "length" => StreamStopReason::MaxTokens, other => StreamStopReason::from_api_str(other).unwrap_or(StreamStopReason::EndTurn), }; - - self.push(StreamEvent::MessageDelta(MessageDelta { - delta: MessageDeltaPayload { - stop_reason: Some(stop_reason.to_api_str().into()), - }, - usage: None, - })); + self.pending_stop_reason = Some(stop_reason); + } + + /// Emit the deferred [`MessageDelta`](StreamEvent::MessageDelta), if one + /// is pending. + /// + /// Called by [`process_chunk`](Self::process_chunk) when the usage chunk + /// arrives, and by [`finish`](Self::finish) as a last resort. Emits the + /// `MessageDelta` carrying the pending stop reason and whatever usage has + /// been captured so far, then clears the pending state so it fires at + /// most once. + fn flush_message_delta(&mut self) { + if let Some(stop_reason) = self.pending_stop_reason.take() { + self.push(StreamEvent::MessageDelta(MessageDelta { + delta: MessageDeltaPayload { + stop_reason: Some(stop_reason.to_api_str().into()), + }, + usage: self.pending_usage, + })); + } } /// Finalize the stream, emitting the terminal /// [`MessageStop`](StreamEvent::MessageStop) if one was started. /// - /// Drains any remaining pending events and appends the stop event. - /// Called exactly once at the end of the SSE stream. + /// Flushes any deferred [`MessageDelta`](StreamEvent::MessageDelta) + /// (when no usage chunk arrived), drains remaining pending events, and + /// appends the stop event. Called exactly once at the end of the SSE + /// stream. fn finish(&mut self) -> Vec { + self.flush_message_delta(); let mut out = self.drain(); if self.started { out.push(StreamEvent::MessageStop); @@ -1309,6 +1505,123 @@ mod tests { assert_eq!(body.to_json(false)["stream"], false); } + #[test] + fn request_body_streaming_includes_usage_option() { + let msgs = vec![Message::user("hi")]; + let body = RequestBody::build("gpt-4o", &msgs, None, None, None, &ToolConstraint::None); + + assert_eq!(body.to_json(true)["stream_options"]["include_usage"], true); + } + + #[test] + fn request_body_non_streaming_omits_usage_option() { + let msgs = vec![Message::user("hi")]; + let body = RequestBody::build("gpt-4o", &msgs, None, None, None, &ToolConstraint::None); + + assert!( + body.to_json(false).get("stream_options").is_none(), + "stream_options should only be present when streaming" + ); + } + + #[test] + fn request_body_stream_usage_disabled_omits_stream_options() { + let msgs = vec![Message::user("hi")]; + let body = RequestBody::build("gpt-4o", &msgs, None, None, None, &ToolConstraint::None) + .with_stream_usage(false); + + assert!( + body.to_json(true).get("stream_options").is_none(), + "stream_options should be absent when stream_usage is disabled" + ); + } + + #[test] + #[cfg(feature = "ollama")] + fn ollama_constructor_disables_stream_usage() { + let client = crate::provider::ollama("test-model").unwrap(); + assert!( + !client.stream_usage, + "ollama() should disable stream_usage for compatibility" + ); + } + + #[test] + fn default_builder_enables_stream_usage() { + let client = OpenAiClient::builder() + .with_api_key("test") + .build() + .unwrap(); + assert!( + client.stream_usage, + "default builder should enable stream_usage" + ); + } + + #[test] + fn emitter_usage_chunk_after_finish_carries_usage_in_delta() { + let mut em = StreamEmitter::default(); + + let text = OpenAiChunk::parse( + r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"content":"hi"},"finish_reason":null}]}"#, + ) + .unwrap(); + em.process_chunk(&text); + em.drain(); + + let finish = OpenAiChunk::parse( + r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":null,"finish_reason":"stop"}]}"#, + ) + .unwrap(); + em.process_chunk(&finish); + em.drain(); + + let usage_chunk = OpenAiChunk::parse( + r#"{"id":"c1","model":"gpt-4o","choices":[],"usage":{"prompt_tokens":10,"completion_tokens":5}}"#, + ) + .unwrap(); + em.process_chunk(&usage_chunk); + let events = em.drain(); + + let delta = events.iter().find_map(|e| match e { + StreamEvent::MessageDelta(md) => Some(md), + _ => None, + }); + let delta = delta.expect("MessageDelta from usage chunk"); + assert_eq!(delta.delta.stop_reason.as_deref(), Some("end_turn")); + let usage = delta.usage.expect("usage should be present"); + assert_eq!(usage.input_tokens, 10); + assert_eq!(usage.output_tokens, 5); + } + + #[test] + fn emitter_finish_without_usage_chunk_emits_delta_with_none_usage() { + let mut em = StreamEmitter::default(); + + let text = OpenAiChunk::parse( + r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"content":"hi"},"finish_reason":null}]}"#, + ) + .unwrap(); + em.process_chunk(&text); + em.drain(); + + let finish = OpenAiChunk::parse( + r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":null,"finish_reason":"stop"}]}"#, + ) + .unwrap(); + em.process_chunk(&finish); + em.drain(); + + let events = em.finish(); + let delta = events.iter().find_map(|e| match e { + StreamEvent::MessageDelta(md) => Some(md), + _ => None, + }); + let delta = delta.expect("MessageDelta from finish"); + assert_eq!(delta.delta.stop_reason.as_deref(), Some("end_turn")); + assert!(delta.usage.is_none()); + } + #[test] fn request_body_model_and_tools() { let msgs = vec![Message::user("hi")]; @@ -1391,6 +1704,7 @@ mod tests { Role::User, vec![MessagePart::ToolResult { call_id: "call_1".into(), + name: "echo".into(), output: ToolContent::from_string("result text"), is_error: None, }], @@ -1408,11 +1722,13 @@ mod tests { vec![ MessagePart::ToolResult { call_id: "call_1".into(), + name: "echo".into(), output: ToolContent::from_string("a"), is_error: None, }, MessagePart::ToolResult { call_id: "call_2".into(), + name: "echo".into(), output: ToolContent::from_string("b"), is_error: None, }, @@ -1492,6 +1808,108 @@ mod tests { assert_eq!(chunk.id, "abc"); assert_eq!(chunk.model, "gpt-4o"); assert_eq!(chunk.choices.len(), 1); + assert!(chunk.usage.is_none()); + } + + #[test] + fn parse_chunk_missing_usage_defaults_to_none() { + let data = r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"content":"hi"},"finish_reason":null}]}"#; + let chunk = OpenAiChunk::parse(data).unwrap(); + assert!(chunk.usage.is_none()); + } + + #[test] + fn parse_final_chunk_with_partial_usage_defaults_missing_fields() { + let data = r#"{"id":"c1","model":"gpt-4o","choices":[],"usage":{"prompt_tokens":15}}"#; + let chunk = OpenAiChunk::parse(data).unwrap(); + let usage = chunk.usage.as_ref().expect("usage should parse"); + assert_eq!(usage.prompt_tokens, 15); + assert_eq!(usage.completion_tokens, 0); + } + + #[test] + fn parse_final_chunk_with_usage() { + let data = r#"{"id":"c1","model":"gpt-4o","choices":[],"usage":{"prompt_tokens":42,"completion_tokens":7,"total_tokens":49}}"#; + let chunk = OpenAiChunk::parse(data).unwrap(); + assert!(chunk.choices.is_empty()); + let usage = chunk.usage.as_ref().expect("usage"); + assert_eq!(usage.prompt_tokens, 42); + assert_eq!(usage.completion_tokens, 7); + let typed: Usage = usage.into(); + assert_eq!(typed.input_tokens, 42); + assert_eq!(typed.output_tokens, 7); + } + + #[test] + fn emitter_usage_and_finish_in_same_chunk() { + let mut em = StreamEmitter::default(); + + let text = OpenAiChunk::parse( + r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"content":"hi"},"finish_reason":null}]}"#, + ) + .unwrap(); + em.process_chunk(&text); + em.drain(); + + let combined = OpenAiChunk::parse( + r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":null,"finish_reason":"stop"}],"usage":{"prompt_tokens":8,"completion_tokens":3}}"#, + ) + .unwrap(); + em.process_chunk(&combined); + let events = em.drain(); + + let delta = events.iter().find_map(|e| match e { + StreamEvent::MessageDelta(md) => Some(md), + _ => None, + }); + let delta = delta.expect("MessageDelta from combined chunk"); + assert_eq!(delta.delta.stop_reason.as_deref(), Some("end_turn")); + let usage = delta.usage.expect("usage"); + assert_eq!(usage.input_tokens, 8); + assert_eq!(usage.output_tokens, 3); + } + + #[test] + fn emitter_tool_call_stream_with_usage_chunk() { + let mut em = StreamEmitter::default(); + + let open = OpenAiChunk::parse( + r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"search","arguments":""}}]},"finish_reason":null}]}"#, + ) + .unwrap(); + em.process_chunk(&open); + em.drain(); + + let args = OpenAiChunk::parse( + r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"q\":\"rust\"}"}}]},"finish_reason":null}]}"#, + ) + .unwrap(); + em.process_chunk(&args); + em.drain(); + + let finish = OpenAiChunk::parse( + r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":null,"finish_reason":"tool_calls"}]}"#, + ) + .unwrap(); + em.process_chunk(&finish); + em.drain(); + + let usage_chunk = OpenAiChunk::parse( + r#"{"id":"c1","model":"gpt-4o","choices":[],"usage":{"prompt_tokens":50,"completion_tokens":20}}"#, + ) + .unwrap(); + em.process_chunk(&usage_chunk); + let events = em.drain(); + + let delta = events.iter().find_map(|e| match e { + StreamEvent::MessageDelta(md) => Some(md), + _ => None, + }); + let delta = delta.expect("MessageDelta after usage chunk"); + assert_eq!(delta.delta.stop_reason.as_deref(), Some("tool_call")); + let usage = delta.usage.expect("usage"); + assert_eq!(usage.input_tokens, 50); + assert_eq!(usage.output_tokens, 20); } #[test] @@ -1731,7 +2149,6 @@ mod tests { fn emitter_finish_emits_part_stops_and_message_delta() { let mut em = StreamEmitter::default(); - // Send some text so the text part is open. let chunk = OpenAiChunk::parse( r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"content":"hi"},"finish_reason":null}]}"#, ) @@ -1739,18 +2156,21 @@ mod tests { em.process_chunk(&chunk); em.drain(); - // Now send finish_reason=stop. let finish = OpenAiChunk::parse( r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":null,"finish_reason":"stop"}]}"#, ) .unwrap(); em.process_chunk(&finish); - let events = em.drain(); + let part_stop_events = em.drain(); + assert_eq!(part_stop_events.len(), 1); + assert!(matches!(part_stop_events[0], StreamEvent::PartStop)); - // PartStop + MessageDelta(stop_reason) - assert_eq!(events.len(), 2); - assert!(matches!(events[0], StreamEvent::PartStop)); - assert!(matches!(events[1], StreamEvent::MessageDelta(_))); + let events = em.finish(); + assert!( + events + .iter() + .any(|e| matches!(e, StreamEvent::MessageDelta(_))) + ); } #[test] @@ -1764,7 +2184,6 @@ mod tests { em.process_chunk(&chunk0); em.drain(); - // Open a tool call. let tool_chunk = OpenAiChunk::parse( r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","function":{"name":"echo","arguments":""}}]},"finish_reason":null}]}"#, ) @@ -1772,23 +2191,22 @@ mod tests { em.process_chunk(&tool_chunk); em.drain(); - // Finish with tool_calls. let finish = OpenAiChunk::parse( r#"{"id":"c1","model":"gpt-4o","choices":[{"delta":null,"finish_reason":"tool_calls"}]}"#, ) .unwrap(); em.process_chunk(&finish); - let events = em.drain(); - - // 1 PartStop (for tool) + MessageDelta - assert_eq!(events.len(), 2); - assert!(matches!(events[0], StreamEvent::PartStop)); + let part_stop_events = em.drain(); + assert_eq!(part_stop_events.len(), 1); + assert!(matches!(part_stop_events[0], StreamEvent::PartStop)); - if let StreamEvent::MessageDelta(md) = &events[1] { - assert_eq!(md.delta.stop_reason.as_deref(), Some("tool_call")); - } else { - panic!("expected MessageDelta"); - } + let events = em.finish(); + let delta = events.iter().find_map(|e| match e { + StreamEvent::MessageDelta(md) => Some(md), + _ => None, + }); + let delta = delta.expect("MessageDelta"); + assert_eq!(delta.delta.stop_reason.as_deref(), Some("tool_call")); } #[test] @@ -1837,13 +2255,15 @@ mod tests { ) .unwrap(); em.process_chunk(&finish); - let events = em.drain(); + em.drain(); - if let StreamEvent::MessageDelta(md) = &events[1] { - assert_eq!(md.delta.stop_reason.as_deref(), Some("max_tokens")); - } else { - panic!("expected MessageDelta"); - } + let events = em.finish(); + let delta = events.iter().find_map(|e| match e { + StreamEvent::MessageDelta(md) => Some(md), + _ => None, + }); + let delta = delta.expect("MessageDelta"); + assert_eq!(delta.delta.stop_reason.as_deref(), Some("max_tokens")); } #[test] @@ -2061,56 +2481,219 @@ mod tests { } #[test] - fn extract_structured_from_string_content() { + fn extract_structured_from_text_part() { let client = OpenAiClient::builder() .with_api_key("test") .build() .unwrap(); - let raw = serde_json::json!({ - "choices": [{ - "message": { - "content": r#"{"tool": "write", "args": {}}"# - } - }] - }); - let value = client.extract_structured(&raw); + let message = Message::assistant(r#"{"tool": "write", "args": {}}"#); + let value = client.extract_structured(&message); assert_eq!(value["tool"], "write"); } #[test] - fn extract_structured_from_object_content() { + fn extract_structured_from_tool_call_part() { let client = OpenAiClient::builder() .with_api_key("test") .build() .unwrap(); - let raw = serde_json::json!({ - "choices": [{ - "message": { - "content": {"tool": "read", "args": {}} - } - }] - }); - let value = client.extract_structured(&raw); + let message = Message::new( + Role::Assistant, + vec![MessagePart::tool_call( + "tc_1", + "action", + serde_json::json!({"tool": "read", "args": {}}), + )], + ); + let value = client.extract_structured(&message); assert_eq!(value["tool"], "read"); } #[test] - fn extract_structured_prose_falls_back_to_raw() { + fn extract_structured_prose_falls_back_to_string() { let client = OpenAiClient::builder() .with_api_key("test") .build() .unwrap(); + let message = Message::assistant("I cannot produce that."); + let value = client.extract_structured(&message); + assert_eq!(value, serde_json::json!("I cannot produce that.")); + } + + #[test] + fn build_response_maps_text_and_stop_finish_reason() { + let raw = serde_json::json!({ + "choices": [{ + "message": {"content": "hello"}, + "finish_reason": "stop" + }] + }); + let response = OpenAiClient::build_response(&raw); + assert_eq!(response.message.role, Role::Assistant); + assert_eq!(response.message.text_content(), "hello"); + assert_eq!(response.stop_reason, StreamStopReason::EndTurn); + } + + #[test] + fn build_response_maps_tool_calls_finish_reason() { let raw = serde_json::json!({ "choices": [{ "message": { - "content": "I cannot produce that." - } + "content": null, + "tool_calls": [{ + "id": "call_1", + "function": {"name": "search", "arguments": "{\"q\": \"x\"}"} + }] + }, + "finish_reason": "tool_calls" }] }); - let value = client.extract_structured(&raw); - // When content is prose (not parseable JSON), falls back to the - // string value; T::from_value will then fail with Deserialize. - assert_eq!(value, serde_json::json!("I cannot produce that.")); + let response = OpenAiClient::build_response(&raw); + assert_eq!(response.message.parts.len(), 1); + match &response.message.parts[0] { + MessagePart::ToolCall { id, name, input } => { + assert_eq!(id, "call_1"); + assert_eq!(name, "search"); + assert_eq!(input, &serde_json::json!({"q": "x"})); + } + other => panic!("expected ToolCall, got {other:?}"), + } + assert_eq!(response.stop_reason, StreamStopReason::ToolCall); + } + + #[test] + fn build_response_maps_length_to_max_tokens() { + let raw = serde_json::json!({ + "choices": [{ + "message": {"content": "truncated"}, + "finish_reason": "length" + }] + }); + let response = OpenAiClient::build_response(&raw); + assert_eq!(response.stop_reason, StreamStopReason::MaxTokens); + } + + #[test] + fn build_response_extracts_usage() { + let raw = serde_json::json!({ + "choices": [{ + "message": {"content": "hi"}, + "finish_reason": "stop" + }], + "usage": {"prompt_tokens": 42, "completion_tokens": 7} + }); + let response = OpenAiClient::build_response(&raw); + assert_eq!(response.usage.expect("usage").input_tokens, 42); + assert_eq!(response.usage.expect("usage").output_tokens, 7); + assert_eq!(response.usage.expect("usage").total_tokens(), 49); + } + + #[test] + fn build_response_missing_usage_is_none() { + let raw = serde_json::json!({ + "choices": [{ + "message": {"content": "hi"}, + "finish_reason": "stop" + }] + }); + let response = OpenAiClient::build_response(&raw); + assert!(response.usage.is_none()); + } + + #[test] + fn build_response_text_and_tool_calls_combined() { + let raw = serde_json::json!({ + "choices": [{ + "message": { + "content": "Let me search", + "tool_calls": [{ + "id": "call_1", + "function": {"name": "search", "arguments": "{\"q\": \"x\"}"} + }] + }, + "finish_reason": "tool_calls" + }] + }); + let response = OpenAiClient::build_response(&raw); + assert_eq!(response.message.parts.len(), 2); + assert!(response.message.parts[0].is_text()); + assert!(response.message.parts[1].is_tool_call()); + assert_eq!(response.stop_reason, StreamStopReason::ToolCall); + } + + #[test] + fn build_response_multiple_tool_calls_preserve_order() { + let raw = serde_json::json!({ + "choices": [{ + "message": { + "content": null, + "tool_calls": [ + {"id": "a", "function": {"name": "first", "arguments": "{}"}}, + {"id": "b", "function": {"name": "second", "arguments": "{\"n\": 2}"}} + ] + }, + "finish_reason": "tool_calls" + }] + }); + let response = OpenAiClient::build_response(&raw); + assert_eq!(response.message.parts.len(), 2); + match &response.message.parts[0] { + MessagePart::ToolCall { id, name, .. } => { + assert_eq!(id, "a"); + assert_eq!(name, "first"); + } + other => panic!("expected ToolCall, got {other:?}"), + } + match &response.message.parts[1] { + MessagePart::ToolCall { id, name, .. } => { + assert_eq!(id, "b"); + assert_eq!(name, "second"); + } + other => panic!("expected ToolCall, got {other:?}"), + } + } + + #[test] + fn build_response_malformed_arguments_defaults_to_empty_object() { + let raw = serde_json::json!({ + "choices": [{ + "message": { + "content": null, + "tool_calls": [{ + "id": "call_1", + "function": {"name": "search", "arguments": "not valid json{"} + }] + }, + "finish_reason": "tool_calls" + }] + }); + let response = OpenAiClient::build_response(&raw); + match &response.message.parts[0] { + MessagePart::ToolCall { input, .. } => { + assert_eq!(input, &serde_json::json!({})); + } + other => panic!("expected ToolCall, got {other:?}"), + } + } + + #[test] + fn build_response_missing_choices_yields_empty_message() { + let raw = serde_json::json!({}); + let response = OpenAiClient::build_response(&raw); + assert!(response.message.parts.is_empty()); + assert_eq!(response.stop_reason, StreamStopReason::EndTurn); + } + + #[test] + fn build_response_unrecognized_finish_reason_defaults_to_end_turn() { + let raw = serde_json::json!({ + "choices": [{ + "message": {"content": "hi"}, + "finish_reason": "content_filter" + }] + }); + let response = OpenAiClient::build_response(&raw); + assert_eq!(response.stop_reason, StreamStopReason::EndTurn); } #[test] diff --git a/src/reflection/llm.rs b/src/reflection/llm.rs index 47002a7..898e0b7 100644 --- a/src/reflection/llm.rs +++ b/src/reflection/llm.rs @@ -304,16 +304,29 @@ mod tests { fn create_message( &self, _request: &crate::api::StreamRequest, - ) -> Pin> + Send + '_>> - { - Box::pin(async { Ok(serde_json::json!({})) }) + ) -> Pin< + Box< + dyn Future> + Send + '_, + >, + > { + let message = crate::message::Message::assistant(self.response.to_string()); + Box::pin(async move { + Ok(crate::api::NonStreamingResponse { + message, + stop_reason: crate::stream::StreamStopReason::EndTurn, + usage: Some(crate::stream::Usage::default()), + }) + }) } fn create_message_with_options( &self, request: &crate::api::StreamRequest, _options: RequestOptions, - ) -> Pin> + Send + '_>> - { + ) -> Pin< + Box< + dyn Future> + Send + '_, + >, + > { let crate::api::StreamRequest { messages, system, @@ -329,11 +342,14 @@ mod tests { system: system.clone(), user, }); - let response = self.response.clone(); - Box::pin(async move { Ok(response) }) - } - fn extract_structured(&self, raw: &serde_json::Value) -> serde_json::Value { - raw.clone() + let message = crate::message::Message::assistant(self.response.to_string()); + Box::pin(async move { + Ok(crate::api::NonStreamingResponse { + message, + stop_reason: crate::stream::StreamStopReason::EndTurn, + usage: Some(crate::stream::Usage::default()), + }) + }) } } @@ -358,16 +374,28 @@ mod tests { fn create_message( &self, _request: &crate::api::StreamRequest, - ) -> Pin> + Send + '_>> - { - Box::pin(async { Ok(serde_json::json!({})) }) + ) -> Pin< + Box< + dyn Future> + Send + '_, + >, + > { + Box::pin(async { + Ok(crate::api::NonStreamingResponse { + message: crate::message::Message::assistant(""), + stop_reason: crate::stream::StreamStopReason::EndTurn, + usage: Some(crate::stream::Usage::default()), + }) + }) } fn create_message_with_options( &self, _request: &crate::api::StreamRequest, _options: RequestOptions, - ) -> Pin> + Send + '_>> - { + ) -> Pin< + Box< + dyn Future> + Send + '_, + >, + > { Box::pin(async { Err(ApiError::http("upstream 500".to_string())) }) } } @@ -394,20 +422,29 @@ mod tests { fn create_message( &self, request: &crate::api::StreamRequest, - ) -> Pin> + Send + '_>> - { + ) -> Pin< + Box< + dyn Future> + Send + '_, + >, + > { self.0.create_message(request) } fn create_message_with_options( &self, _request: &crate::api::StreamRequest, _options: RequestOptions, - ) -> Pin> + Send + '_>> - { - Box::pin(async { Ok(serde_json::json!("I cannot produce that.")) }) - } - fn extract_structured(&self, raw: &serde_json::Value) -> serde_json::Value { - raw.clone() + ) -> Pin< + Box< + dyn Future> + Send + '_, + >, + > { + Box::pin(async { + Ok(crate::api::NonStreamingResponse { + message: crate::message::Message::assistant("I cannot produce that."), + stop_reason: crate::stream::StreamStopReason::EndTurn, + usage: Some(crate::stream::Usage::default()), + }) + }) } } diff --git a/src/stream.rs b/src/stream.rs index 9370e20..8a71948 100644 --- a/src/stream.rs +++ b/src/stream.rs @@ -548,12 +548,18 @@ pub enum StreamStopReason { } impl StreamStopReason { - /// Parse a stop reason from the API string representation. + /// Parse a stop reason from the provider's API string representation. /// - /// Called when deserializing [`MessageDeltaPayload`] events to - /// convert the string stop reason into a typed enum. Returns - /// `None` for unrecognized strings, which may indicate a new - /// API version has introduced additional stop reasons. + /// Called in two places: when deserializing [`MessageDeltaPayload`] + /// streaming events, and when each provider's `build_response` maps its + /// native finish/stop field on the non-streaming path. Returns `None` for + /// unrecognized strings, which may indicate a new API version has + /// introduced additional stop reasons. + /// + /// `"tool_use"` is accepted as an alias for `"tool_call"` because Anthropic + /// reports a tool-invocation stop reason as `"tool_use"` while OpenAI uses + /// `"tool_calls"` (handled directly in the OpenAI provider) — both map to + /// [`ToolCall`](Self::ToolCall). /// /// # Returns /// @@ -565,12 +571,13 @@ impl StreamStopReason { /// use loopctl::stream::StreamStopReason; /// /// assert_eq!(StreamStopReason::from_api_str("tool_call"), Some(StreamStopReason::ToolCall)); + /// assert_eq!(StreamStopReason::from_api_str("tool_use"), Some(StreamStopReason::ToolCall)); /// assert_eq!(StreamStopReason::from_api_str("unknown"), None); /// ``` #[must_use] pub fn from_api_str(s: &str) -> Option { match s { - "tool_call" => Some(Self::ToolCall), + "tool_call" | "tool_use" => Some(Self::ToolCall), "max_tokens" => Some(Self::MaxTokens), "stop_sequence" => Some(Self::StopSequence), "end_turn" => Some(Self::EndTurn), @@ -732,7 +739,7 @@ pub struct MessageDeltaPayload { /// assert_eq!(usage.output_tokens, 75); /// assert_eq!(usage.total_tokens(), 225); /// ``` -#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)] pub struct Usage { /// Number of tokens in the input prompt. /// @@ -1199,6 +1206,10 @@ mod tests { StreamStopReason::from_api_str("tool_call"), Some(StreamStopReason::ToolCall) ); + assert_eq!( + StreamStopReason::from_api_str("tool_use"), + Some(StreamStopReason::ToolCall) + ); assert_eq!( StreamStopReason::from_api_str("max_tokens"), Some(StreamStopReason::MaxTokens) diff --git a/src/stream/handler.rs b/src/stream/handler.rs index 37738e7..eba2b9a 100644 --- a/src/stream/handler.rs +++ b/src/stream/handler.rs @@ -36,11 +36,10 @@ use crate::api::ApiClient; use crate::api::error::{ApiError, http_status_is_overload}; use crate::cancel::CancelSignal; -use crate::message::{Message, MessagePart}; -use crate::stream::{StreamAccumulator, StreamEvent, StreamStopReason}; +use crate::message::Message; +use crate::stream::{StreamAccumulator, StreamEvent, StreamStopReason, Usage}; use futures::StreamExt; use futures::stream::Stream; -use serde_json::Value; use std::fmt; use std::pin::Pin; use std::sync::Arc; @@ -1736,7 +1735,7 @@ impl StreamHandler { return; } ErrorAction::TryFallback(outcome) => { - let (message, fallback_stop_reason) = self + let (message, fallback_stop_reason, fallback_usage) = self .fallback_non_streaming( client, request, @@ -1747,6 +1746,7 @@ impl StreamHandler { yield HandlerEvent::Fallback { message, stop_reason: fallback_stop_reason, + usage: fallback_usage, }; return; } @@ -2051,7 +2051,9 @@ impl StreamHandler { /// /// Called when streaming fails (timeout, retries exhausted) and /// `fallback_to_non_streaming` is enabled. Uses - /// [`ApiClient::create_message`] to get a complete response. + /// [`ApiClient::create_message`] to get a complete typed response — the + /// message, stop reason, and token usage are returned directly, with no + /// JSON parsing at this layer. /// /// # Errors /// @@ -2064,7 +2066,7 @@ impl StreamHandler { request: &crate::api::StreamRequest, cancel: &Arc, stream_outcome: Option, - ) -> Result<(Message, StreamStopReason), StreamHandlerError> { + ) -> Result<(Message, StreamStopReason, Option), StreamHandlerError> { if cancel.is_cancelled() { return Err(StreamHandlerError::Cancelled); } @@ -2077,49 +2079,7 @@ impl StreamHandler { }; match result { - Ok(value) => { - let parts = value - .get("content") - .and_then(|c| c.as_array()) - .map(|blocks| { - blocks - .iter() - .filter_map(|block| match block.get("type").and_then(|t| t.as_str()) { - Some("text") => block - .get("text") - .and_then(|t| t.as_str()) - .map(MessagePart::text), - Some("tool_use") => { - let id = block.get("id").and_then(|v| v.as_str()).unwrap_or(""); - let name = - block.get("name").and_then(|v| v.as_str()).unwrap_or(""); - let input = block.get("input").cloned().unwrap_or(Value::Null); - Some(MessagePart::tool_call(id, name, input)) - } - _ => None, - }) - .collect::>() - }) - .unwrap_or_default(); - let stop_reason = value - .get("stop_reason") - .and_then(|r| r.as_str()) - .and_then(StreamStopReason::from_api_str) - .unwrap_or(StreamStopReason::EndTurn); - let message = if parts.is_empty() { - let text = value - .get("choices") - .and_then(|c| c.get(0)) - .and_then(|c| c.get("message")) - .and_then(|m| m.get("content")) - .and_then(|t| t.as_str()) - .unwrap_or(""); - Message::assistant(text) - } else { - Message::new(crate::message::Role::Assistant, parts) - }; - Ok((message, stop_reason)) - } + Ok(response) => Ok((response.message, response.stop_reason, response.usage)), Err(e) => Err(StreamHandlerError::FallbackFailed { stream_outcome: stream_outcome.unwrap_or(StreamOutcome::InitFailed { attempts: 0, @@ -2163,9 +2123,9 @@ pub enum HandlerEvent { /// Streaming retries are exhausted and the non-streaming fallback /// succeeded. /// - /// Carries the final message and stop reason extracted from the + /// Carries the final message, stop reason, and token usage from the /// non-streaming - /// [`create_message`](crate::api::ApiClient::create_message) JSON + /// [`create_message`](crate::api::ApiClient::create_message) typed /// response. The engine should stop accumulating and use these directly — /// the streaming accumulator's partial state from failed attempts is /// irrelevant on this path. @@ -2176,21 +2136,28 @@ pub enum HandlerEvent { /// The fallback assistant message produced by the non-streaming /// request. /// - /// Built from the first text part of the - /// [`create_message`](crate::api::ApiClient::create_message) JSON - /// response. The engine should treat this as the authoritative - /// turn output — the streaming accumulator's partial state from - /// failed attempts is discarded on this path. + /// Built from the typed + /// [`NonStreamingResponse`](crate::api::NonStreamingResponse) returned + /// by [`create_message`](crate::api::ApiClient::create_message). The + /// engine should treat this as the authoritative turn output — the + /// streaming accumulator's partial state from failed attempts is + /// discarded on this path. message: Message, - /// Stop reason parsed from the JSON response's `stop_reason` - /// field. + /// Stop reason mapped from the provider's native finish/stop field. /// /// Defaults to [`EndTurn`](StreamStopReason::EndTurn) when the /// field is absent or holds an unrecognized value, so the engine /// always has a concrete reason to act on. Drives the same /// downstream behaviour as a streaming `MessageStop`. stop_reason: StreamStopReason, + + /// Token usage reported by the provider for the fallback request. + /// + /// `None` when the provider omits usage from its non-streaming + /// response. The engine threads this into the turn's usage totals + /// exactly like the `MessageDelta` usage on the streaming path. + usage: Option, }, } @@ -2254,6 +2221,7 @@ mod tests { HandlerEvent::Fallback { message, stop_reason: fallback_stop_reason, + .. } => { from_fallback = true; return Ok(DriveResult { @@ -2761,7 +2729,7 @@ mod tests { struct HandlerMock { create_error: Option, - create_response: Option, + create_response: Option, } impl HandlerMock { @@ -2773,10 +2741,7 @@ mod tests { } fn with_text_response(mut self, text: &str) -> Self { - self.create_response = Some(serde_json::json!({ - "content": [{"type": "text", "text": text}], - "stop_reason": "end_turn" - })); + self.create_response = Some(Message::assistant(text)); self } @@ -2805,17 +2770,27 @@ mod tests { &self, _request: &crate::api::StreamRequest, ) -> std::pin::Pin< - Box> + Send + '_>, + Box< + dyn std::future::Future> + + Send + + '_, + >, > { if let Some(ref err) = self.create_error { let err = err.clone(); return Box::pin(async move { Err(ApiError::api(&err)) }); } - let val = self.create_response.clone().unwrap_or(serde_json::json!({ - "content": [{"type": "text", "text": "default"}], - "stop_reason": "end_turn" - })); - Box::pin(async move { Ok(val) }) + let message = self + .create_response + .clone() + .unwrap_or_else(|| Message::assistant("default")); + Box::pin(async move { + Ok(crate::api::NonStreamingResponse { + message, + stop_reason: crate::stream::StreamStopReason::EndTurn, + usage: Some(crate::stream::Usage::default()), + }) + }) } } @@ -2828,7 +2803,7 @@ mod tests { let client = HandlerMock::new().with_text_response("fallback works"); let cancel = Arc::new(CancelSignal::new()); - let (message, stop_reason) = handler + let (message, stop_reason, usage) = handler .fallback_non_streaming( &client, &crate::api::StreamRequest::new(vec![]), @@ -2854,6 +2829,8 @@ mod tests { assert!(text.contains("fallback works"), "got: {text:?}"); // HandlerMock::with_text_response sets stop_reason: "end_turn". assert_eq!(stop_reason, StreamStopReason::EndTurn); + // HandlerMock returns Usage::default() (zero tokens). + assert_eq!(usage, Some(Usage::default())); } #[tokio::test] @@ -2973,12 +2950,19 @@ mod tests { _request: &crate::api::StreamRequest, ) -> std::pin::Pin< Box< - dyn std::future::Future> - + Send + dyn std::future::Future< + Output = Result, + > + Send + '_, >, > { - Box::pin(async { Ok(serde_json::json!({})) }) + Box::pin(async { + Ok(crate::api::NonStreamingResponse { + message: crate::message::Message::assistant(""), + stop_reason: crate::stream::StreamStopReason::EndTurn, + usage: Some(crate::stream::Usage::default()), + }) + }) } } @@ -3045,19 +3029,28 @@ mod tests { _request: &crate::api::StreamRequest, ) -> std::pin::Pin< Box< - dyn std::future::Future> - + Send + dyn std::future::Future< + Output = Result, + > + Send + '_, >, > { Box::pin(async { - Ok(serde_json::json!({ - "content": [ - {"type": "text", "text": "Let me search"}, - {"type": "tool_use", "id": "tc_1", "name": "search", "input": {"q": "hello"}} - ], - "stop_reason": "tool_use" - })) + Ok(crate::api::NonStreamingResponse { + message: crate::message::Message::new( + crate::message::Role::Assistant, + vec![ + crate::message::MessagePart::text("Let me search"), + crate::message::MessagePart::tool_call( + "tc_1", + "search", + serde_json::json!({"q": "hello"}), + ), + ], + ), + stop_reason: crate::stream::StreamStopReason::ToolCall, + usage: Some(crate::stream::Usage::default()), + }) }) } } @@ -3129,16 +3122,18 @@ mod tests { _request: &crate::api::StreamRequest, ) -> std::pin::Pin< Box< - dyn std::future::Future> - + Send + dyn std::future::Future< + Output = Result, + > + Send + '_, >, > { Box::pin(async { - Ok(serde_json::json!({ - "content": [{"type": "text", "text": "fallback ok"}], - "stop_reason": "end_turn" - })) + Ok(crate::api::NonStreamingResponse { + message: crate::message::Message::assistant("fallback ok"), + stop_reason: crate::stream::StreamStopReason::EndTurn, + usage: Some(crate::stream::Usage::default()), + }) }) } } @@ -3192,7 +3187,11 @@ mod tests { &self, request: &crate::api::StreamRequest, ) -> Pin< - Box> + Send + '_>, + Box< + dyn std::future::Future> + + Send + + '_, + >, > { self.create_message_with_options(request, crate::structured::RequestOptions::default()) } @@ -3225,11 +3224,21 @@ mod tests { _request: &crate::api::StreamRequest, _options: crate::structured::RequestOptions, ) -> Pin< - Box> + Send + '_>, + Box< + dyn std::future::Future> + + Send + + '_, + >, > { - Box::pin(async { Ok(serde_json::Value::Null) }) + Box::pin(async { + Ok(crate::api::NonStreamingResponse { + message: crate::message::Message::assistant(""), + stop_reason: crate::stream::StreamStopReason::EndTurn, + usage: Some(crate::stream::Usage::default()), + }) + }) } - fn extract_structured(&self, _: &serde_json::Value) -> serde_json::Value { + fn extract_structured(&self, _: &crate::message::Message) -> serde_json::Value { serde_json::Value::Null } } @@ -3329,8 +3338,9 @@ mod tests { _request: &crate::api::StreamRequest, ) -> std::pin::Pin< Box< - dyn std::future::Future> - + Send + dyn std::future::Future< + Output = Result, + > + Send + '_, >, > { @@ -3395,13 +3405,18 @@ mod tests { &self, _request: &crate::api::StreamRequest, ) -> Pin< - Box> + Send + '_>, + Box< + dyn std::future::Future> + + Send + + '_, + >, > { Box::pin(async { - Ok(serde_json::json!({ - "content": [{"type": "text", "text": "fallback answer"}], - "stop_reason": "max_tokens", - })) + Ok(crate::api::NonStreamingResponse { + message: crate::message::Message::assistant("fallback answer"), + stop_reason: crate::stream::StreamStopReason::MaxTokens, + usage: Some(crate::stream::Usage::default()), + }) }) } fn stream_messages_with_options( @@ -3418,16 +3433,21 @@ mod tests { _request: &crate::api::StreamRequest, _options: crate::structured::RequestOptions, ) -> Pin< - Box> + Send + '_>, + Box< + dyn std::future::Future> + + Send + + '_, + >, > { Box::pin(async { - Ok(serde_json::json!({ - "content": [{"type": "text", "text": "fallback answer"}], - "stop_reason": "max_tokens", - })) + Ok(crate::api::NonStreamingResponse { + message: crate::message::Message::assistant("fallback answer"), + stop_reason: crate::stream::StreamStopReason::MaxTokens, + usage: Some(crate::stream::Usage::default()), + }) }) } - fn extract_structured(&self, _: &serde_json::Value) -> serde_json::Value { + fn extract_structured(&self, _: &crate::message::Message) -> serde_json::Value { serde_json::Value::Null } } @@ -3957,9 +3977,19 @@ mod tests { &self, _request: &crate::api::StreamRequest, ) -> std::pin::Pin< - Box> + Send + '_>, + Box< + dyn std::future::Future> + + Send + + '_, + >, > { - Box::pin(async { Ok(serde_json::json!({})) }) + Box::pin(async { + Ok(crate::api::NonStreamingResponse { + message: crate::message::Message::assistant(""), + stop_reason: crate::stream::StreamStopReason::EndTurn, + usage: Some(crate::stream::Usage::default()), + }) + }) } } @@ -4210,12 +4240,19 @@ mod tests { _request: &crate::api::StreamRequest, ) -> std::pin::Pin< Box< - dyn std::future::Future> - + Send + dyn std::future::Future< + Output = Result, + > + Send + '_, >, > { - Box::pin(async { Ok(serde_json::json!({})) }) + Box::pin(async { + Ok(crate::api::NonStreamingResponse { + message: crate::message::Message::assistant(""), + stop_reason: crate::stream::StreamStopReason::EndTurn, + usage: Some(crate::stream::Usage::default()), + }) + }) } } @@ -4277,12 +4314,19 @@ mod tests { _request: &crate::api::StreamRequest, ) -> std::pin::Pin< Box< - dyn std::future::Future> - + Send + dyn std::future::Future< + Output = Result, + > + Send + '_, >, > { - Box::pin(async { Ok(serde_json::json!({})) }) + Box::pin(async { + Ok(crate::api::NonStreamingResponse { + message: crate::message::Message::assistant(""), + stop_reason: crate::stream::StreamStopReason::EndTurn, + usage: Some(crate::stream::Usage::default()), + }) + }) } } @@ -4351,12 +4395,19 @@ mod tests { _request: &crate::api::StreamRequest, ) -> std::pin::Pin< Box< - dyn std::future::Future> - + Send + dyn std::future::Future< + Output = Result, + > + Send + '_, >, > { - Box::pin(async { Ok(serde_json::json!({})) }) + Box::pin(async { + Ok(crate::api::NonStreamingResponse { + message: crate::message::Message::assistant(""), + stop_reason: crate::stream::StreamStopReason::EndTurn, + usage: Some(crate::stream::Usage::default()), + }) + }) } } @@ -4423,12 +4474,19 @@ mod tests { _request: &crate::api::StreamRequest, ) -> std::pin::Pin< Box< - dyn std::future::Future> - + Send + dyn std::future::Future< + Output = Result, + > + Send + '_, >, > { - Box::pin(async { Ok(serde_json::json!({})) }) + Box::pin(async { + Ok(crate::api::NonStreamingResponse { + message: crate::message::Message::assistant(""), + stop_reason: crate::stream::StreamStopReason::EndTurn, + usage: Some(crate::stream::Usage::default()), + }) + }) } } @@ -4497,12 +4555,19 @@ mod tests { _request: &crate::api::StreamRequest, ) -> std::pin::Pin< Box< - dyn std::future::Future> - + Send + dyn std::future::Future< + Output = Result, + > + Send + '_, >, > { - Box::pin(async { Ok(serde_json::json!({})) }) + Box::pin(async { + Ok(crate::api::NonStreamingResponse { + message: crate::message::Message::assistant(""), + stop_reason: crate::stream::StreamStopReason::EndTurn, + usage: Some(crate::stream::Usage::default()), + }) + }) } } @@ -4557,12 +4622,19 @@ mod tests { _request: &crate::api::StreamRequest, ) -> std::pin::Pin< Box< - dyn std::future::Future> - + Send + dyn std::future::Future< + Output = Result, + > + Send + '_, >, > { - Box::pin(async { Ok(serde_json::json!({})) }) + Box::pin(async { + Ok(crate::api::NonStreamingResponse { + message: crate::message::Message::assistant(""), + stop_reason: crate::stream::StreamStopReason::EndTurn, + usage: Some(crate::stream::Usage::default()), + }) + }) } } @@ -4616,12 +4688,19 @@ mod tests { _request: &crate::api::StreamRequest, ) -> std::pin::Pin< Box< - dyn std::future::Future> - + Send + dyn std::future::Future< + Output = Result, + > + Send + '_, >, > { - Box::pin(async { Ok(serde_json::json!({})) }) + Box::pin(async { + Ok(crate::api::NonStreamingResponse { + message: crate::message::Message::assistant(""), + stop_reason: crate::stream::StreamStopReason::EndTurn, + usage: Some(crate::stream::Usage::default()), + }) + }) } } @@ -4690,12 +4769,19 @@ mod tests { _request: &crate::api::StreamRequest, ) -> std::pin::Pin< Box< - dyn std::future::Future> - + Send + dyn std::future::Future< + Output = Result, + > + Send + '_, >, > { - Box::pin(async { Ok(serde_json::json!({})) }) + Box::pin(async { + Ok(crate::api::NonStreamingResponse { + message: crate::message::Message::assistant(""), + stop_reason: crate::stream::StreamStopReason::EndTurn, + usage: Some(crate::stream::Usage::default()), + }) + }) } } diff --git a/src/structured.rs b/src/structured.rs index 4a618f8..44892cf 100644 --- a/src/structured.rs +++ b/src/structured.rs @@ -340,7 +340,6 @@ pub enum StructuredError { /// /// Returns `None` if the content cannot be parsed as JSON (even after the /// lenient rescue). -#[cfg(any(feature = "openai", feature = "gemini"))] pub(crate) fn parse_json_lenient(text: &str) -> Option { if let Ok(v) = serde_json::from_str(text) { return Some(v); @@ -355,7 +354,6 @@ pub(crate) fn parse_json_lenient(text: &str) -> Option { /// the substring up to the matching close. String-aware: braces/brackets /// inside JSON string literals (`"..."`) do not affect depth, and `\"` /// escapes are honored. -#[cfg(any(feature = "openai", feature = "gemini"))] pub(crate) fn extract_json_substring(text: &str) -> Option { let bytes = text.as_bytes(); let mut start = None; @@ -603,11 +601,11 @@ pub async fn request_structured Pin< Box< - dyn Future> - + Send + dyn Future< + Output = Result< + crate::api::NonStreamingResponse, + crate::api::error::ApiError, + >, + > + Send + '_, >, > { - Box::pin(async { Ok(serde_json::json!({})) }) + Box::pin(async { + Ok(crate::api::NonStreamingResponse { + message: crate::message::Message::assistant(""), + stop_reason: crate::stream::StreamStopReason::EndTurn, + usage: Some(crate::stream::Usage::default()), + }) + }) } } @@ -843,12 +851,22 @@ mod tests { _request: &crate::api::StreamRequest, ) -> Pin< Box< - dyn Future> - + Send + dyn Future< + Output = Result< + crate::api::NonStreamingResponse, + crate::api::error::ApiError, + >, + > + Send + '_, >, > { - Box::pin(async { Ok(serde_json::json!({})) }) + Box::pin(async { + Ok(crate::api::NonStreamingResponse { + message: crate::message::Message::assistant(""), + stop_reason: crate::stream::StreamStopReason::EndTurn, + usage: Some(crate::stream::Usage::default()), + }) + }) } fn create_message_with_options( &self, @@ -856,16 +874,23 @@ mod tests { _options: RequestOptions, ) -> Pin< Box< - dyn Future> - + Send + dyn Future< + Output = Result< + crate::api::NonStreamingResponse, + crate::api::error::ApiError, + >, + > + Send + '_, >, > { Box::pin(async { - Ok(serde_json::json!({ - "tool": "write", - "args": {"path": "/test"} - })) + Ok(crate::api::NonStreamingResponse { + message: crate::message::Message::assistant( + r#"{"tool": "write", "args": {"path": "/test"}}"#, + ), + stop_reason: crate::stream::StreamStopReason::EndTurn, + usage: Some(crate::stream::Usage::default()), + }) }) } } @@ -902,12 +927,22 @@ mod tests { _request: &crate::api::StreamRequest, ) -> Pin< Box< - dyn Future> - + Send + dyn Future< + Output = Result< + crate::api::NonStreamingResponse, + crate::api::error::ApiError, + >, + > + Send + '_, >, > { - Box::pin(async { Ok(serde_json::json!({})) }) + Box::pin(async { + Ok(crate::api::NonStreamingResponse { + message: crate::message::Message::assistant(""), + stop_reason: crate::stream::StreamStopReason::EndTurn, + usage: Some(crate::stream::Usage::default()), + }) + }) } fn create_message_with_options( &self, @@ -915,12 +950,22 @@ mod tests { _options: RequestOptions, ) -> Pin< Box< - dyn Future> - + Send + dyn Future< + Output = Result< + crate::api::NonStreamingResponse, + crate::api::error::ApiError, + >, + > + Send + '_, >, > { - Box::pin(async { Ok(serde_json::json!("I cannot produce that.")) }) + Box::pin(async { + Ok(crate::api::NonStreamingResponse { + message: crate::message::Message::assistant("I cannot produce that."), + stop_reason: crate::stream::StreamStopReason::EndTurn, + usage: Some(crate::stream::Usage::default()), + }) + }) } } diff --git a/src/testing.rs b/src/testing.rs index af287d9..c5d1422 100644 --- a/src/testing.rs +++ b/src/testing.rs @@ -696,11 +696,13 @@ impl ApiClient for MockApiClient { Box::pin(futures::stream::iter(events)) } - /// Return a canned non-streaming JSON response. + /// Return a canned non-streaming [`NonStreamingResponse`](crate::api::NonStreamingResponse). /// /// Called by code paths that use the non-streaming API. The mock - /// returns a JSON object with a `content` array containing a single - /// text block drawn from the current [`MockResponse`]. + /// translates the current [`MockResponse`] into a typed + /// [`NonStreamingResponse`](crate::api::NonStreamingResponse) carrying an + /// assistant [`Message`] built from the response's text and optional tool + /// call, plus the stop reason parsed from [`MockResponse::stop_reason`]. /// /// If [`with_error`](MockApiClient::with_error) was called the /// future resolves to an [`ApiError`] instead, bypassing the @@ -717,13 +719,14 @@ impl ApiClient for MockApiClient { /// /// let client = MockApiClient::new("test-model").with_text_response("Hi!"); /// let result = client.create_message(&StreamRequest::new(vec![])).await; - /// assert_eq!(result.unwrap()["content"][0]["text"], "Hi!"); + /// assert_eq!(result.unwrap().message.text_content(), "Hi!"); /// # }); /// ``` fn create_message( &self, _request: &crate::api::StreamRequest, - ) -> Pin> + Send + '_>> { + ) -> Pin> + Send + '_>> + { if let Some(ref err) = self.error { let err = err.clone(); return Box::pin(async move { Err(ApiError::api(&err)) }); @@ -731,9 +734,18 @@ impl ApiClient for MockApiClient { let response = self.pop_response(); Box::pin(async move { - Ok(json!({ - "content": [{"type": "text", "text": response.text}] - })) + let stop_reason = crate::stream::StreamStopReason::from_api_str(&response.stop_reason) + .unwrap_or(crate::stream::StreamStopReason::EndTurn); + let parts = if let Some(tc) = response.tool_call { + vec![MessagePart::tool_call(tc.id, tc.name, tc.input)] + } else { + vec![MessagePart::text(response.text)] + }; + Ok(crate::api::NonStreamingResponse { + message: Message::new(Role::Assistant, parts), + stop_reason, + usage: Some(crate::stream::Usage::default()), + }) }) } } @@ -1507,8 +1519,12 @@ mod tests { }) .await; assert!(result.is_ok()); - let json = result.unwrap(); - assert_eq!(json["content"][0]["text"], "Hello!"); + let response = result.unwrap(); + assert_eq!(response.message.text_content(), "Hello!"); + assert_eq!( + response.stop_reason, + crate::stream::StreamStopReason::EndTurn + ); } #[tokio::test] From 3bd23ba37dc8547faf2dba795a95a2ee446fa9d9 Mon Sep 17 00:00:00 2001 From: Iurii Bobrykov Date: Sat, 1 Aug 2026 00:20:44 +1200 Subject: [PATCH 16/20] fix: collapse zero-usage to None, surface invalid SSE UTF-8, add tcp_nodelay builder --- CHANGELOG.md | 9 +++++++ src/engine/bare.rs | 22 +++++++++++------ src/engine/core/machine.rs | 20 ++++++++-------- src/provider.rs | 18 ++++++++++++++ src/provider/anthropic.rs | 38 ++++++++++++++++++++---------- src/provider/gemini.rs | 39 ++++++++++++++++++++----------- src/provider/openai.rs | 29 ++++++++++++++++------- src/provider/sse.rs | 48 ++++++++++++++++++++++++++++++++------ 8 files changed, 165 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f83ad21..4e50a3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -309,6 +309,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/2.0.0. - **OpenAI usage unification:** the non-streaming `build_response` path now uses the same `OpenAiUsage` serde struct as the streaming path, deleting the manual `extract_usage` helper. Both paths share one deserialization strategy. +- **SSE invalid-UTF-8 handling:** `SseReader::take_line` now returns + `Result, ApiError>` and surfaces genuinely invalid UTF-8 as a + protocol error instead of silently replacing bytes with `U+FFFD` via + `String::from_utf8_lossy`. Callers (`next_openai_data`, `next_anthropic_data`, + `next_gemini_data`) propagate the error via `?`. +- **`with_tcp_nodelay` setter:** all three provider builders + (`OpenAiClientBuilder`, `AnthropicClientBuilder`, `GeminiClientBuilder`) and + `HttpClientConfig` now expose `with_tcp_nodelay(bool)`. The field was + previously hardcoded to `true` with no escape hatch. Default remains `true`. - **Breaking (session→run lifecycle rename):** the observer and hook events formerly named `on_session_start` / `on_session_end` are renamed to `on_run_start` / `on_run_end`. These events fire once per `run()` call and diff --git a/src/engine/bare.rs b/src/engine/bare.rs index a1e0ac3..7531970 100644 --- a/src/engine/bare.rs +++ b/src/engine/bare.rs @@ -388,20 +388,18 @@ impl BareLoop { /// Borrow the in-flight run (the last entry in `session.runs`). /// - /// Constructors seed `session.runs` with a placeholder, and `run()` - /// pushes a fresh [`Run`] before any access — so the last entry is - /// always present. + /// Returns `None` before the first `run()` call — the session starts + /// with an empty run list, and `run()` pushes a fresh [`Run`] before + /// any access. fn current_run(&self) -> Option<&Run> { - let len = self.session.runs.len(); - self.session.runs.get(len.saturating_sub(1)) + self.session.runs.last() } /// Mutably borrow the in-flight run. /// /// Same contract as [`current_run`](Self::current_run) but `&mut`. fn current_run_mut(&mut self) -> Option<&mut Run> { - let len = self.session.runs.len(); - self.session.runs.get_mut(len.saturating_sub(1)) + self.session.runs.last_mut() } /// Get the run configuration for the current run, if a run has started. @@ -2466,6 +2464,16 @@ mod tests { ); } + #[test] + fn session_starts_with_empty_runs() { + let client = MockClient::new("test-model"); + let agent = BareLoop::new(Arc::new(client), ToolRegistry::new(), make_config()); + assert!( + agent.session.runs.is_empty(), + "a never-run session must have zero runs, not a placeholder" + ); + } + #[tokio::test] async fn run_config_is_some_after_run() { let client = MockClient::new("test-model"); diff --git a/src/engine/core/machine.rs b/src/engine/core/machine.rs index 20add84..72a5d9d 100644 --- a/src/engine/core/machine.rs +++ b/src/engine/core/machine.rs @@ -822,7 +822,7 @@ mod tests { use super::*; use serde_json::Value; - fn small_machine(_max_turns: usize) -> LoopMachine { + fn small_machine() -> LoopMachine { LoopMachine::from_history(vec![Message::user("hello")]) } @@ -906,7 +906,7 @@ mod tests { #[test] fn resume_after_model_response_round_trips() { - let mut machine = small_machine(5); + let mut machine = small_machine(); let _ = machine.next_step(test_policy(5)); machine.model_response(tool_response("echo", &["echo"], 10), 0); let snapshot = serde_json::to_string(&machine).expect("serialize"); @@ -918,7 +918,7 @@ mod tests { #[test] fn resume_after_tool_results_round_trips() { - let mut machine = small_machine(5); + let mut machine = small_machine(); let _ = machine.next_step(test_policy(5)); machine.model_response(tool_response("echo", &["echo"], 10), 0); let step = machine.next_step(test_policy(5)); @@ -974,7 +974,7 @@ mod tests { #[test] fn max_turns_enforced_by_machine() { - let mut machine = small_machine(2); + let mut machine = small_machine(); // Turn 1. assert!(matches!( machine.next_step(test_policy(2)), @@ -1001,7 +1001,7 @@ mod tests { #[test] fn cancel_returns_done_cancelled_at_next_step() { - let mut machine = small_machine(5); + let mut machine = small_machine(); let _ = machine.next_step(test_policy(5)); machine.cancel(); match machine.next_step(test_policy(5)) { @@ -1017,7 +1017,7 @@ mod tests { #[test] fn fail_returns_done_failed_at_next_step() { - let mut machine = small_machine(5); + let mut machine = small_machine(); let _ = machine.next_step(test_policy(5)); let err = LoopError::Api("stream failed".to_string()); machine.fail(err.clone()); @@ -1032,7 +1032,7 @@ mod tests { #[test] fn failed_outcome_survives_round_trip() { - let mut machine = small_machine(5); + let mut machine = small_machine(); let _ = machine.next_step(test_policy(5)); let err = LoopError::Api("stream failed".to_string()); machine.fail(err.clone()); @@ -1048,7 +1048,7 @@ mod tests { #[test] fn unknown_tool_call_gets_preresolved_result() { - let mut machine = small_machine(5); + let mut machine = small_machine(); let _ = machine.next_step(test_policy(5)); machine.model_response(tool_response("ghost", &["echo", "ls"], 3), 0); let step = machine.next_step(test_policy(5)); @@ -1069,7 +1069,7 @@ mod tests { #[test] fn known_tool_call_emits_plain_pending_call() { - let mut machine = small_machine(5); + let mut machine = small_machine(); let _ = machine.next_step(test_policy(5)); machine.model_response(tool_response("echo", &["echo", "ls"], 3), 0); let step = machine.next_step(test_policy(5)); @@ -1280,7 +1280,7 @@ mod tests { #[test] fn history_accumulates_user_assistant_tool_round() { - let mut machine = small_machine(5); + let mut machine = small_machine(); let _ = machine.next_step(test_policy(5)); machine.model_response(tool_response("echo", &["echo"], 1), 0); let step = machine.next_step(test_policy(5)); diff --git a/src/provider.rs b/src/provider.rs index 752b1a4..b3617a1 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -276,6 +276,18 @@ impl HttpClientConfig { self } + /// Control whether `TCP_NODELAY` is set on connections. + /// + /// Defaults to `true` — SSE streaming emits many small packets, and + /// Nagle's algorithm coalesces them, adding latency per delta. Pass + /// `false` to re-enable Nagle's algorithm (rarely needed). Ignored when + /// an external client was supplied. + #[must_use] + pub(super) fn with_tcp_nodelay(mut self, enabled: bool) -> Self { + self.tcp_nodelay = enabled; + self + } + /// Build a `reqwest::Client` from this configuration. /// /// If an external client was supplied via @@ -1002,6 +1014,12 @@ mod tests { assert!(HttpClientConfig::default().tcp_nodelay); } + #[test] + fn with_tcp_nodelay_can_disable() { + let config = HttpClientConfig::default().with_tcp_nodelay(false); + assert!(!config.tcp_nodelay); + } + #[test] fn injected_client_ignores_pool_knobs() { let shared = reqwest::Client::new(); diff --git a/src/provider/anthropic.rs b/src/provider/anthropic.rs index 127a9aa..3545da7 100644 --- a/src/provider/anthropic.rs +++ b/src/provider/anthropic.rs @@ -241,8 +241,8 @@ impl AnthropicClient { /// Extract token [`Usage`] from Anthropic's native `usage` object. /// /// Reads `usage.input_tokens` and `usage.output_tokens`. Returns `None` when -/// the `usage` object is absent from the response. Mirrors the extraction the -/// streaming emitter performs in `on_message_delta`. +/// the `usage` object is absent or when both counts are zero, matching the +/// convention used by the streaming emitter in `on_message_delta`. fn extract_usage(raw: &Value) -> Option { let usage = raw.get("usage")?; let input = usage @@ -255,7 +255,7 @@ fn extract_usage(raw: &Value) -> Option { .and_then(Value::as_u64) .and_then(|n| u32::try_from(n).ok()) .unwrap_or(0); - Some(Usage::new(input, output)) + (input > 0 || output > 0).then(|| Usage::new(input, output)) } impl ApiClient for AnthropicClient { @@ -522,6 +522,17 @@ impl AnthropicClientBuilder { self } + /// Control whether `TCP_NODELAY` is set on connections. + /// + /// Defaults to `true` — SSE streaming benefits from disabling Nagle's + /// algorithm. Pass `false` to re-enable it. Ignored when a client was + /// supplied via [`with_http_client`](Self::with_http_client). + #[must_use] + pub fn with_tcp_nodelay(mut self, enabled: bool) -> Self { + self.http = self.http.with_tcp_nodelay(enabled); + self + } + /// Construct the [`AnthropicClient`] from the builder's configuration. /// /// Creates the internal `reqwest::Client` with the configured timeouts, @@ -810,7 +821,7 @@ impl SseReader { let mut have_event = false; loop { - while let Some(line) = self.take_line() { + while let Some(line) = self.take_line()? { if line.is_empty() { if have_event { let parsed = Self::parse_event_data(&data, &event_type); @@ -1832,7 +1843,7 @@ mod tests { bytes: Box::pin(futures::stream::empty()), buf: "event: ping\n".into(), }; - assert_eq!(reader.take_line().unwrap(), "event: ping"); + assert_eq!(reader.take_line().unwrap().unwrap(), "event: ping"); assert!(reader.buf.is_empty()); } @@ -1842,7 +1853,7 @@ mod tests { bytes: Box::pin(futures::stream::empty()), buf: "partial".into(), }; - assert!(reader.take_line().is_none()); + assert!(reader.take_line().unwrap().is_none()); } #[test] @@ -1851,8 +1862,8 @@ mod tests { bytes: Box::pin(futures::stream::empty()), buf: "line1\nline2\n".into(), }; - assert_eq!(reader.take_line().unwrap(), "line1"); - assert_eq!(reader.take_line().unwrap(), "line2"); + assert_eq!(reader.take_line().unwrap().unwrap(), "line1"); + assert_eq!(reader.take_line().unwrap().unwrap(), "line2"); } #[test] @@ -1861,7 +1872,7 @@ mod tests { bytes: Box::pin(futures::stream::empty()), buf: "data: hi\r\n".into(), }; - assert_eq!(reader.take_line().unwrap(), "data: hi"); + assert_eq!(reader.take_line().unwrap().unwrap(), "data: hi"); } #[test] @@ -1882,9 +1893,12 @@ mod tests { .to_string() .into_bytes(), }; - assert_eq!(reader.take_line(), Some("event: message_start".to_string())); - assert_eq!(reader.take_line(), Some("data: {}".to_string())); - assert_eq!(reader.take_line(), Some(String::new())); + assert_eq!( + reader.take_line().unwrap(), + Some("event: message_start".to_string()) + ); + assert_eq!(reader.take_line().unwrap(), Some("data: {}".to_string())); + assert_eq!(reader.take_line().unwrap(), Some(String::new())); } #[tokio::test] diff --git a/src/provider/gemini.rs b/src/provider/gemini.rs index c756698..cb413d6 100644 --- a/src/provider/gemini.rs +++ b/src/provider/gemini.rs @@ -257,9 +257,9 @@ impl GeminiClient { /// /// Reads `usageMetadata.promptTokenCount` for input tokens and sums /// `candidatesTokenCount` plus `thoughtsTokenCount` for output. Returns `None` -/// when the `usageMetadata` object is absent from the response. This mirrors -/// the streaming path, which reads the same fields from the finish chunk to -/// populate the `MessageDelta` usage. +/// when the `usageMetadata` object is absent or when all counts are zero, +/// matching the convention used by the streaming emitter in +/// `extract_finish_reason`. fn extract_usage(raw: &Value) -> Option { let usage = raw.pointer("/usageMetadata")?; let input = usage @@ -277,7 +277,8 @@ fn extract_usage(raw: &Value) -> Option { .and_then(Value::as_u64) .and_then(|n| u32::try_from(n).ok()) .unwrap_or(0); - Some(Usage::new(input, output.saturating_add(thoughts))) + let total_output = output.saturating_add(thoughts); + (input > 0 || total_output > 0).then(|| Usage::new(input, total_output)) } impl ApiClient for GeminiClient { @@ -588,6 +589,17 @@ impl GeminiClientBuilder { self } + /// Control whether `TCP_NODELAY` is set on connections. + /// + /// Defaults to `true` — SSE streaming benefits from disabling Nagle's + /// algorithm. Pass `false` to re-enable it. Ignored when a client was + /// supplied via [`with_http_client`](Self::with_http_client). + #[must_use] + pub fn with_tcp_nodelay(mut self, enabled: bool) -> Self { + self.http = self.http.with_tcp_nodelay(enabled); + self + } + /// Build the client. /// /// # Errors @@ -772,7 +784,7 @@ impl SseReader { /// Returns [`ApiError`] if the underlying HTTP stream fails. async fn next_gemini_data(&mut self) -> Result, ApiError> { loop { - while let Some(line) = self.take_line() { + while let Some(line) = self.take_line()? { if line.is_empty() { continue; } @@ -1193,6 +1205,7 @@ mod tests { let parts = body["contents"][0]["parts"].as_array().unwrap(); assert_eq!(parts[0]["functionCall"]["name"], "echo"); + assert_eq!(parts[0]["functionCall"]["id"], "call_1"); assert_eq!(parts[0]["functionCall"]["args"]["msg"], "hi"); } @@ -2073,7 +2086,7 @@ mod tests { bytes: Box::pin(futures::stream::empty()), buf: "data: hello\n".into(), }; - assert_eq!(reader.take_line().unwrap(), "data: hello"); + assert_eq!(reader.take_line().unwrap().unwrap(), "data: hello"); assert!(reader.buf.is_empty()); } @@ -2083,7 +2096,7 @@ mod tests { bytes: Box::pin(futures::stream::empty()), buf: "partial".into(), }; - assert!(reader.take_line().is_none()); + assert!(reader.take_line().unwrap().is_none()); } #[test] @@ -2092,8 +2105,8 @@ mod tests { bytes: Box::pin(futures::stream::empty()), buf: "line1\nline2\n".into(), }; - assert_eq!(reader.take_line().unwrap(), "line1"); - assert_eq!(reader.take_line().unwrap(), "line2"); + assert_eq!(reader.take_line().unwrap().unwrap(), "line1"); + assert_eq!(reader.take_line().unwrap().unwrap(), "line2"); } #[test] @@ -2102,7 +2115,7 @@ mod tests { bytes: Box::pin(futures::stream::empty()), buf: "data: hi\r\n".into(), }; - assert_eq!(reader.take_line().unwrap(), "data: hi"); + assert_eq!(reader.take_line().unwrap().unwrap(), "data: hi"); } #[test] @@ -2147,11 +2160,11 @@ mod tests { buf: "data: {\"candidates\":[]}\n\n".to_string().into_bytes(), }; assert_eq!( - reader.take_line(), + reader.take_line().unwrap(), Some("data: {\"candidates\":[]}".to_string()) ); - assert_eq!(reader.take_line(), Some(String::new())); - assert_eq!(reader.take_line(), None); + assert_eq!(reader.take_line().unwrap(), Some(String::new())); + assert_eq!(reader.take_line().unwrap(), None); } #[tokio::test] diff --git a/src/provider/openai.rs b/src/provider/openai.rs index 65cdeff..71a28d1 100644 --- a/src/provider/openai.rs +++ b/src/provider/openai.rs @@ -577,6 +577,17 @@ impl OpenAiClientBuilder { self } + /// Control whether `TCP_NODELAY` is set on connections. + /// + /// Defaults to `true` — SSE streaming benefits from disabling Nagle's + /// algorithm. Pass `false` to re-enable it. Ignored when a client was + /// supplied via [`with_http_client`](Self::with_http_client). + #[must_use] + pub fn with_tcp_nodelay(mut self, enabled: bool) -> Self { + self.http = self.http.with_tcp_nodelay(enabled); + self + } + /// Build the client. /// /// # Errors @@ -913,7 +924,7 @@ impl SseReader { /// Returns [`ApiError`] if the underlying HTTP stream fails. async fn next_openai_data(&mut self) -> Result, ApiError> { loop { - while let Some(line) = self.take_line() { + while let Some(line) = self.take_line()? { let Some(data) = line.strip_prefix(SSE_DATA_PREFIX) else { continue; }; @@ -2314,7 +2325,7 @@ mod tests { bytes: Box::pin(futures::stream::empty()), buf: "data: hello\n".into(), }; - let line = reader.take_line().unwrap(); + let line = reader.take_line().unwrap().unwrap(); assert_eq!(line, "data: hello"); assert!(reader.buf.is_empty()); } @@ -2325,7 +2336,7 @@ mod tests { bytes: Box::pin(futures::stream::empty()), buf: "partial".into(), }; - assert!(reader.take_line().is_none()); + assert!(reader.take_line().unwrap().is_none()); } #[test] @@ -2334,8 +2345,8 @@ mod tests { bytes: Box::pin(futures::stream::empty()), buf: "line1\nline2\n".into(), }; - assert_eq!(reader.take_line().unwrap(), "line1"); - assert_eq!(reader.take_line().unwrap(), "line2"); + assert_eq!(reader.take_line().unwrap().unwrap(), "line1"); + assert_eq!(reader.take_line().unwrap().unwrap(), "line2"); } #[test] @@ -2344,7 +2355,7 @@ mod tests { bytes: Box::pin(futures::stream::empty()), buf: "data: hi\r\n".into(), }; - let line = reader.take_line().unwrap(); + let line = reader.take_line().unwrap().unwrap(); assert_eq!(line, "data: hi"); } @@ -2364,9 +2375,9 @@ mod tests { bytes: Box::pin(futures::stream::empty()), buf: "data: hello\ndata: world\n".to_string().into_bytes(), }; - assert_eq!(reader.take_line(), Some("data: hello".to_string())); - assert_eq!(reader.take_line(), Some("data: world".to_string())); - assert_eq!(reader.take_line(), None); + assert_eq!(reader.take_line().unwrap(), Some("data: hello".to_string())); + assert_eq!(reader.take_line().unwrap(), Some("data: world".to_string())); + assert_eq!(reader.take_line().unwrap(), None); } #[tokio::test] diff --git a/src/provider/sse.rs b/src/provider/sse.rs index ab8b69a..43b1e6e 100644 --- a/src/provider/sse.rs +++ b/src/provider/sse.rs @@ -62,15 +62,23 @@ impl SseReader { /// /// Returns the trimmed line as a `String` if a newline is present, /// removing it (plus the newline) from the buffer. Returns `None` if - /// the buffer does not yet contain a complete line. Uses - /// `String::from_utf8_lossy` on the complete line only — by this point - /// any split multi-byte sequence has been reassembled. - pub(super) fn take_line(&mut self) -> Option { - let pos = self.buf.iter().position(|&b| b == b'\n')?; + /// the buffer does not yet contain a complete line. + /// + /// # Errors + /// + /// Returns [`ApiError`] if the complete line contains invalid UTF-8. + /// Split multi-byte sequences are reassembled by this point (buffering is + /// byte-oriented), so an error indicates genuinely malformed data from + /// the provider — not a chunk-boundary artifact. + pub(super) fn take_line(&mut self) -> Result, ApiError> { + let Some(pos) = self.buf.iter().position(|&b| b == b'\n') else { + return Ok(None); + }; let rest_start = pos.saturating_add(1); let line_bytes: Vec = self.buf.drain(..rest_start).collect(); - let line = String::from_utf8_lossy(&line_bytes); - Some(line.trim().to_string()) + let line = String::from_utf8(line_bytes) + .map_err(|e| ApiError::http(format!("SSE line is not valid UTF-8: {e}")))?; + Ok(Some(line.trim().to_string())) } /// Fetch the next chunk from the HTTP stream and append it to the buffer. @@ -98,3 +106,29 @@ impl SseReader { } } } + +#[cfg(any(feature = "openai", feature = "anthropic", feature = "gemini"))] +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn take_line_invalid_utf8_returns_error() { + let mut reader = SseReader { + bytes: Box::pin(futures::stream::empty()), + buf: vec![0xFF, 0xFE, 0xFD, b'\n'], + }; + let result = reader.take_line(); + assert!(result.is_err(), "invalid UTF-8 must surface as an error"); + } + + #[test] + fn take_line_valid_utf8_returns_ok() { + let mut reader = SseReader { + bytes: Box::pin(futures::stream::empty()), + buf: b"data: hello\n".to_vec(), + }; + let line = reader.take_line().unwrap().unwrap(); + assert_eq!(line, "data: hello"); + } +} From 2c1abce426c120b51d811815ed4f2c33b03f8897 Mon Sep 17 00:00:00 2001 From: Iurii Bobrykov Date: Sat, 1 Aug 2026 12:26:39 +1200 Subject: [PATCH 17/20] fix: context token count, memory role, usage propagation, doc and test cleanup --- src/compact.rs | 24 ++---- src/engine/bare.rs | 160 ++++++++++++------------------------ src/engine/bare/dispatch.rs | 23 +++++- src/message.rs | 19 ++++- src/provider/gemini.rs | 19 +++-- src/stream/handler.rs | 46 +++++------ 6 files changed, 132 insertions(+), 159 deletions(-) diff --git a/src/compact.rs b/src/compact.rs index 7ef8ba3..dfbacf4 100644 --- a/src/compact.rs +++ b/src/compact.rs @@ -114,22 +114,11 @@ pub trait TokenCounter: Send + Sync { /// /// Counts the character length of all message parts (text, tool calls, tool /// results), adds a fixed per-message overhead for role tags and formatting, -/// and divides by `bytes_per_token`. Conservative — overestimates rather +/// and divides by 4 (`CHARS_PER_TOKEN`). Conservative — overestimates rather /// than underestimates, so compaction triggers slightly early rather than /// late. Accuracy is roughly ±30% on real content; for production use, /// swap in a real tokenizer via the [`TokenCounter`] trait. /// -/// # Presets -/// -/// A zero-dependency token estimator using 4 characters per token. -/// -/// Counts the character length of all message parts (text, tool calls, tool -/// results), adds a fixed per-message overhead for role tags and formatting, -/// and divides by 4. Conservative — overestimates rather than -/// underestimates, so compaction triggers slightly early rather than -/// late. Accuracy is roughly ±30% on real content; for production use, -/// swap in a real tokenizer via the [`TokenCounter`] trait. -/// /// # Example /// /// ```rust @@ -158,16 +147,15 @@ impl TokenCounter for HeuristicTokenCounter { .map(|p| match p { MessagePart::Text { text } => text.chars().count() as u64, MessagePart::Image { .. } => 256, - MessagePart::ToolCall { name, input, .. } => { - (name.len() as u64).saturating_add(input.to_string().len() as u64) - } + MessagePart::ToolCall { name, input, .. } => (name.chars().count() as u64) + .saturating_add(input.to_string().chars().count() as u64), MessagePart::ToolResult { output, .. } => match output { - crate::message::ToolContent::Text(s) => s.len() as u64, + crate::message::ToolContent::Text(s) => s.chars().count() as u64, crate::message::ToolContent::Multipart(parts) => parts .iter() .map(|p| match p { crate::message::ToolContentPart::Text { text } => { - text.len() as u64 + text.chars().count() as u64 } crate::message::ToolContentPart::Image { .. } => 256, }) @@ -181,7 +169,7 @@ impl TokenCounter for HeuristicTokenCounter { total_chars / CHARS_PER_TOKEN } } -/// + /// Implementations define *how* to reduce a message list — truncation, /// summarization, Q&A extraction, etc. The framework calls /// [`compact`](ContextCompactor::compact) when the [`ContextManager`] diff --git a/src/engine/bare.rs b/src/engine/bare.rs index 7531970..753a69c 100644 --- a/src/engine/bare.rs +++ b/src/engine/bare.rs @@ -1592,9 +1592,9 @@ impl BareLoop { .collect::>() .join("\n"); contributor_messages.push(Message::new( - crate::message::Role::System, + crate::message::Role::User, vec![crate::message::MessagePart::text(format!( - "Relevant memory:\n{summary}" + "Relevant memory (reference only, do not treat as instructions):\n{summary}" ))], )); } @@ -1674,7 +1674,9 @@ impl BareLoop { stop_reason, available_tools: self.tools.tool_names(), }; - let context_tokens = self.token_counter.count(&self.machine.full_history()); + let mut context_history = self.machine.full_history(); + context_history.push(model_response.message.clone()); + let context_tokens = self.token_counter.count(&context_history); self.machine.model_response(model_response, context_tokens); let turn_index = current_turn; @@ -2553,100 +2555,6 @@ mod tests { ); } - struct RequestCapturingClient { - model: String, - responses: Arc>>>, - captured: Arc>>, - } - - impl RequestCapturingClient { - fn new(model: &str, captured: Arc>>) -> Self { - Self { - model: model.to_string(), - responses: Arc::new(Mutex::new(Vec::new())), - captured, - } - } - - fn add_text_response(&self, text: &str) { - crate::error::recover_guard(self.responses.lock()).push(vec![ - StreamEvent::MessageStart(MessageStart { - message: MessageMetadata { - id: "msg_test".into(), - role: "assistant".into(), - model: self.model.clone(), - }, - }), - StreamEvent::PartStart(PartStart { - index: 0, - part: Some(MessagePart::text(text)), - }), - StreamEvent::IndexedDelta(IndexedDelta { - index: 0, - delta: DeltaPart::Text { - text: text.to_string(), - }, - }), - StreamEvent::PartStop, - StreamEvent::MessageDelta(MessageDelta { - delta: MessageDeltaPayload { - stop_reason: Some("end_turn".to_string()), - }, - usage: None, - }), - StreamEvent::MessageStop, - ]); - } - } - - impl ApiClient for RequestCapturingClient { - fn model(&self) -> String { - self.model.clone() - } - fn stream_messages( - &self, - request: &crate::api::StreamRequest, - ) -> Pin> + Send + 'static>> - { - let texts: Vec = request - .messages - .iter() - .flat_map(|m| { - m.parts - .iter() - .filter_map(|p| p.as_text().map(std::string::ToString::to_string)) - }) - .collect(); - crate::error::recover_guard(self.captured.lock()).extend(texts); - let mut guard = crate::error::recover_guard(self.responses.lock()); - if let Some(events) = guard.pop_front() { - let events: Vec> = - events.into_iter().map(Ok).collect(); - Box::pin(futures::stream::iter(events)) - } else { - Box::pin(futures::stream::iter(vec![Err(ApiError::api( - "No more mock responses", - ))])) - } - } - fn create_message( - &self, - _request: &crate::api::StreamRequest, - ) -> Pin< - Box< - dyn Future> + Send + '_, - >, - > { - Box::pin(async { - Ok(crate::api::NonStreamingResponse { - message: crate::message::Message::assistant(""), - stop_reason: crate::stream::StreamStopReason::EndTurn, - usage: Some(crate::stream::Usage::default()), - }) - }) - } - } - #[tokio::test] async fn memory_retrieve_injects_into_request() { use crate::memory::{InMemoryStore, LoopMemory, MemoryCategory, MemoryEntry}; @@ -2657,9 +2565,7 @@ mod tests { .await .unwrap(); - let captured: Arc>> = Arc::new(Mutex::new(Vec::new())); - let captured_clone = Arc::clone(&captured); - let client = RequestCapturingClient::new("test", captured_clone); + let client = RecordingClient::new("test"); client.add_text_response("done"); let mut agent = BareLoop::new(Arc::new(client), ToolRegistry::new(), make_config()); @@ -2667,15 +2573,22 @@ mod tests { agent.run("answer", &RunConfig::default()).await.unwrap(); - let msgs = crate::error::recover_guard(captured.lock()).clone(); - let combined = msgs.join(" "); + let seen = agent.client.first_seen(); + let memory_msg = seen + .iter() + .find(|m| m.role == Role::User && m.text_content().contains("Relevant memory")); + assert!( + memory_msg.is_some(), + "memory must be injected as a User-role message" + ); + let text = memory_msg.unwrap().text_content(); assert!( - combined.contains("Relevant memory"), - "request must contain the injected memory message: {combined}" + text.contains("the answer is 42"), + "request must contain the stored entry text: {text}" ); assert!( - combined.contains("the answer is 42"), - "request must contain the stored entry text: {combined}" + text.contains("reference only"), + "memory message must delimit itself as untrusted data" ); } @@ -2924,6 +2837,41 @@ mod tests { ); } + #[tokio::test] + async fn context_token_count_includes_model_response_message() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct CountingCounter { + last_message_count: AtomicUsize, + } + impl crate::compact::TokenCounter for CountingCounter { + fn count(&self, messages: &[Message]) -> u64 { + self.last_message_count + .store(messages.len(), Ordering::SeqCst); + 0 + } + } + + let client = MockClient::new("test-model"); + client.add_text_response("assistant reply"); + + let token_ctr = Arc::new(CountingCounter { + last_message_count: AtomicUsize::new(0), + }); + let counter_clone = Arc::clone(&token_ctr); + + let mut agent = BareLoop::new(Arc::new(client), ToolRegistry::new(), make_config()); + agent.set_token_counter(counter_clone); + + agent.run("hi", &RunConfig::default()).await.unwrap(); + + let seen_msgs = token_ctr.last_message_count.load(Ordering::SeqCst); + assert!( + seen_msgs >= 2, + "token counter must see at least 2 messages (user + model response), got {seen_msgs}" + ); + } + #[tokio::test] async fn compaction_then_failure_leaves_history_compacted() { let client = MockClient::new("test-model"); diff --git a/src/engine/bare/dispatch.rs b/src/engine/bare/dispatch.rs index 2c4c1bb..fc00f98 100644 --- a/src/engine/bare/dispatch.rs +++ b/src/engine/bare/dispatch.rs @@ -6,7 +6,7 @@ /// Truncate a string to `max_len` chars, appending `…` when truncated. fn truncate_to(s: &str, max_len: usize) -> String { - if s.len() <= max_len { + if s.chars().count() <= max_len { return s.to_string(); } let mut cut = s.char_indices().take(max_len).last().map_or(0, |(i, _)| i); @@ -949,6 +949,27 @@ mod tests { use super::*; + #[test] + fn truncate_to_short_string_unchanged() { + assert_eq!(truncate_to("hello", 10), "hello"); + } + + #[test] + fn truncate_to_exact_length_unchanged() { + assert_eq!(truncate_to("hello", 5), "hello"); + } + + #[test] + fn truncate_to_longer_string_appends_ellipsis() { + assert_eq!(truncate_to("hello world", 5), "hello…"); + } + + #[test] + fn truncate_to_multibyte_chars_counts_characters_not_bytes() { + assert_eq!(truncate_to("héllo", 3), "hél…"); + assert_eq!(truncate_to("日本語テスト", 3), "日本語…"); + } + struct MockClient { model_name: Arc>, } diff --git a/src/message.rs b/src/message.rs index 6a2289e..ec2f5d7 100644 --- a/src/message.rs +++ b/src/message.rs @@ -414,8 +414,9 @@ pub enum MessagePart { /// Copied from the original [`ToolCall`](MessagePart::ToolCall)'s /// `name` field. Required by providers that correlate tool responses /// by function name (e.g. Gemini's `functionResponse`), in addition to - /// the `call_id`. Always present for results built by the engine after - /// tool dispatch. + /// the `call_id`. Defaults to an empty string when deserializing older + /// data that predates this field. + #[serde(default)] name: String, /// The output returned by the tool. @@ -1037,6 +1038,20 @@ mod tests { assert_eq!(result.to_string(), "hello"); } + #[test] + fn tool_result_deserializes_without_name_field() { + let json = r#"{"type":"tool_result","call_id":"tc_1","output":"ok","is_error":false}"#; + let part: MessagePart = + serde_json::from_str(json).expect("old data without name must parse"); + match part { + MessagePart::ToolResult { call_id, name, .. } => { + assert_eq!(call_id, "tc_1"); + assert_eq!(name, ""); + } + other => panic!("expected ToolResult, got {other:?}"), + } + } + #[test] fn test_tool_result_default() { let result = ToolContent::default(); diff --git a/src/provider/gemini.rs b/src/provider/gemini.rs index cb413d6..36605e4 100644 --- a/src/provider/gemini.rs +++ b/src/provider/gemini.rs @@ -170,15 +170,16 @@ impl GeminiClient { /// /// Reads `candidates[0].content.parts` into [`MessagePart`]s: each `text` /// field becomes a [`MessagePart::Text`] part and each `functionCall` - /// becomes a [`MessagePart::ToolCall`] (Gemini function calls carry no - /// caller-side id, so the id is left empty — matching the streaming path). - /// A single part may hold both `text` and `functionCall`, in which case it - /// yields two parts. Maps `candidates[0].finishReason` to a - /// [`StreamStopReason`] using the same mapping the streaming emitter - /// applies: `"MAX_TOKENS"` → `MaxTokens`, anything else (including the - /// `"STOP"` default) → `EndTurn`. Reads `usageMetadata.promptTokenCount` - /// and `candidatesTokenCount` (plus `thoughtsTokenCount`) into [`Usage`], - /// defaulting to zero when the object is absent. + /// becomes a [`MessagePart::ToolCall`] with its `id` preserved when + /// present (Gemini 3 assigns a unique id per call; older versions omit + /// it, in which case the id defaults to an empty string). A single part + /// may hold both `text` and `functionCall`, in which case it yields two + /// parts. Maps `candidates[0].finishReason` to a [`StreamStopReason`] + /// using the same mapping the streaming emitter applies: `"MAX_TOKENS"` → + /// `MaxTokens`, anything else (including the `"STOP"` default) → + /// `EndTurn`. Reads `usageMetadata.promptTokenCount` and + /// `candidatesTokenCount` (plus `thoughtsTokenCount`) into [`Usage`], + /// returning `None` when the object is absent or all-zero. fn build_response(raw: &Value) -> crate::api::NonStreamingResponse { let mut parts: Vec = Vec::new(); if let Some(content_parts) = raw diff --git a/src/stream/handler.rs b/src/stream/handler.rs index eba2b9a..76efddc 100644 --- a/src/stream/handler.rs +++ b/src/stream/handler.rs @@ -1531,9 +1531,7 @@ impl StreamHandler { /// Returns a reference to the retry configuration. /// /// Read-only access to the [`StreamRetryConfig`] stored on the handler. - /// Mutate via - /// [`with_retry_config`](Self::with_retry_config). - /// both retry and timeout together); there is no per-field setter. + /// Mutate via [`with_retry_config`](Self::with_retry_config). #[must_use] pub fn retry_config(&self) -> &StreamRetryConfig { &self.retry_config @@ -1762,19 +1760,6 @@ impl StreamHandler { }) } - /// Decide how to handle a rate-limit failure on the current model. - /// - /// Bumps `count` and returns one of: - /// - [`RateLimitRetry::HardStop`] once `count` exceeds - /// [`max_retries`](RateLimitConfig::max_retries) — the absolute ceiling; - /// - [`RateLimitRetry::Escalate`] once `count` exceeds - /// [`fallback_after_retries`](RateLimitConfig::fallback_after_retries) - /// but is still within `max_retries` — the caller escalates to the - /// model circuit breaker; - /// - [`RateLimitRetry::Retry`] with the deadline-clamped backoff otherwise. - /// - /// `max_retries` is checked first so it is always enforced as the hard - /// ceiling, regardless of `fallback_after_retries`. /// Decide how to handle a rate-limit stream error. /// /// Delegates to [`rate_limit_retry`](Self::rate_limit_retry) for the @@ -1847,6 +1832,19 @@ impl StreamHandler { ErrorAction::Retry(delay) } + /// Decide how to handle a rate-limit failure on the current model. + /// + /// Bumps `count` and returns one of: + /// - [`RateLimitRetry::HardStop`] once `count` exceeds + /// [`max_retries`](RateLimitConfig::max_retries) — the absolute ceiling; + /// - [`RateLimitRetry::Escalate`] once `count` exceeds + /// [`fallback_after_retries`](RateLimitConfig::fallback_after_retries) + /// but is still within `max_retries` — the caller escalates to the + /// model circuit breaker; + /// - [`RateLimitRetry::Retry`] with the deadline-clamped backoff otherwise. + /// + /// `max_retries` is checked first so it is always enforced as the hard + /// ceiling, regardless of `fallback_after_retries`. fn rate_limit_retry( &self, detail: &DetectedRateLimit, @@ -2221,12 +2219,12 @@ mod tests { HandlerEvent::Fallback { message, stop_reason: fallback_stop_reason, - .. + usage: fallback_usage, } => { from_fallback = true; return Ok(DriveResult { message, - usage: None, + usage: fallback_usage, stop_reason: fallback_stop_reason, from_fallback, }); @@ -3415,7 +3413,7 @@ mod tests { Ok(crate::api::NonStreamingResponse { message: crate::message::Message::assistant("fallback answer"), stop_reason: crate::stream::StreamStopReason::MaxTokens, - usage: Some(crate::stream::Usage::default()), + usage: Some(crate::stream::Usage::new(42, 13)), }) }) } @@ -3443,7 +3441,7 @@ mod tests { Ok(crate::api::NonStreamingResponse { message: crate::message::Message::assistant("fallback answer"), stop_reason: crate::stream::StreamStopReason::MaxTokens, - usage: Some(crate::stream::Usage::default()), + usage: Some(crate::stream::Usage::new(42, 13)), }) }) } @@ -3500,9 +3498,11 @@ mod tests { text.contains("fallback answer"), "fallback message text, got {text:?}" ); - // Usage is None on the fallback path (non-streaming JSON doesn't - // reliably carry token counts). - assert!(result.usage.is_none()); + assert_eq!( + result.usage, + Some(Usage::new(42, 13)), + "fallback path must propagate usage from the non-streaming response" + ); } #[test] From dc748cbd4e52f71f142fadaf7f7db0b3f2d17f54 Mon Sep 17 00:00:00 2001 From: Iurii Bobrykov Date: Sat, 1 Aug 2026 12:42:39 +1200 Subject: [PATCH 18/20] chore: propagate token counter to the context manager --- src/engine/bare.rs | 60 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 52 insertions(+), 8 deletions(-) diff --git a/src/engine/bare.rs b/src/engine/bare.rs index 753a69c..7a76101 100644 --- a/src/engine/bare.rs +++ b/src/engine/bare.rs @@ -67,7 +67,7 @@ use crate::engine::core::{ use crate::error::LoopError; -use crate::capabilities::{Detectable, FallbackCapable}; +use crate::capabilities::{Compactable, Detectable, FallbackCapable}; use crate::detection::{ConvergenceAction, DetectedPattern}; use crate::engine::{ContextContributor, ContributorContext}; #[cfg(all(test, feature = "hooks"))] @@ -650,12 +650,11 @@ impl BareLoop { /// characters-per-token heuristic); swap in a real tokenizer (e.g. /// `tiktoken` for OpenAI) for better accuracy. /// - /// When a [`ContextManager`] is also set, its counter should match — use - /// [`ContextManager::with_token_counter`] on the manager before passing - /// it to [`set_context_manager`](Self::set_context_manager), which syncs - /// the two automatically. If this method is called *after* - /// `set_context_manager`, only the driver-side estimate changes (the - /// compactor keeps its own counter). + /// If a [`ContextManager`] has already been set, its counter is also + /// replaced so the driver-side estimate and the compactor stay in sync + /// regardless of setter order. This mirrors the reverse sync that + /// [`set_context_manager`](Self::set_context_manager) performs when it + /// copies the manager's counter onto the driver. /// /// Must be called before [`run()`](crate::engine::core::Loop::run). /// @@ -674,7 +673,13 @@ impl BareLoop { /// ``` pub fn set_token_counter(&mut self, counter: Arc) { self.debug_assert_idle(); - self.token_counter = counter; + self.token_counter = Arc::clone(&counter); + if let Some(manager) = self.managers.context_manager().cloned() { + let synced = Arc::try_unwrap(manager) + .unwrap_or_else(|arc| (*arc).clone()) + .with_token_counter(counter); + self.managers.set_context_manager(Arc::new(synced)); + } } /// Set the token counter, consuming `self`. Fluent mirror of @@ -2872,6 +2877,45 @@ mod tests { ); } + #[test] + fn set_token_counter_after_context_manager_syncs_both() { + use crate::compact::{ContextManager, HeuristicTokenCounter, TokenCounter}; + + struct SentinelCounter; + impl TokenCounter for SentinelCounter { + fn count(&self, _: &[Message]) -> u64 { + 999 + } + } + + let client = MockClient::new("test-model"); + let manager = Arc::new( + ContextManager::new(Arc::new(crate::compact::TruncatingCompactor::new())) + .with_token_counter(Arc::new(HeuristicTokenCounter)), + ); + let mut agent = BareLoop::new(Arc::new(client), ToolRegistry::new(), make_config()); + agent.set_context_manager(manager); + + let sentinel = Arc::new(SentinelCounter); + agent.set_token_counter(sentinel); + + let driver_sample = agent.token_counter.count(&[Message::user("hi")]); + assert_eq!( + driver_sample, 999, + "driver-side counter must be the sentinel" + ); + let manager_counter = agent + .managers + .context_manager() + .expect("context manager set") + .token_counter(); + let manager_sample = manager_counter.count(&[Message::user("hi")]); + assert_eq!( + manager_sample, 999, + "context manager's counter must also be the sentinel after set_token_counter" + ); + } + #[tokio::test] async fn compaction_then_failure_leaves_history_compacted() { let client = MockClient::new("test-model"); From 3d766b4147dc5453a71ff073ccdaf26b9d824c27 Mon Sep 17 00:00:00 2001 From: Iurii Bobrykov Date: Sat, 1 Aug 2026 18:43:02 +1200 Subject: [PATCH 19/20] fix: openai provider --- src/provider/openai.rs | 119 +++++++++++++++++++++++++++++++++-------- 1 file changed, 97 insertions(+), 22 deletions(-) diff --git a/src/provider/openai.rs b/src/provider/openai.rs index 71a28d1..7bb9a47 100644 --- a/src/provider/openai.rs +++ b/src/provider/openai.rs @@ -170,8 +170,15 @@ impl OpenAiClient { /// `ToolCall`, `"length"` → `MaxTokens`, anything else via /// [`StreamStopReason::from_api_str`], defaulting to `EndTurn`). Reads /// `usage.prompt_tokens` / `usage.completion_tokens` into [`Usage`], - /// defaulting to zero when the `usage` object is absent. - fn build_response(raw: &Value) -> crate::api::NonStreamingResponse { + /// returning `None` when the object is absent or all-zero. Missing or + /// empty `function.arguments` default to `{}`; non-empty arguments that + /// fail to parse as JSON return an error. + /// + /// # Errors + /// + /// Returns [`ApiError`] if a tool call's `function.arguments` is present, + /// non-empty, and not valid JSON. + fn build_response(raw: &Value) -> Result { let choice = raw.get("choices").and_then(|c| c.get(0)); let message = choice.and_then(|c| c.get("message")); let mut parts: Vec = Vec::new(); @@ -187,11 +194,15 @@ impl OpenAiClient { .and_then(|f| f.get("name")) .and_then(|v| v.as_str()) .unwrap_or(""); - let input = function + let input = match function .and_then(|f| f.get("arguments")) .and_then(|a| a.as_str()) - .and_then(|s| serde_json::from_str::(s).ok()) - .unwrap_or_else(|| serde_json::json!({})); + { + None | Some("") => serde_json::json!({}), + Some(s) => serde_json::from_str::(s).map_err(|e| { + ApiError::http(format!("tool_call arguments is not valid JSON: {e}")) + })?, + }; parts.push(MessagePart::tool_call(id, name, input)); } } @@ -208,12 +219,13 @@ impl OpenAiClient { let usage = raw .get("usage") .and_then(|u| OpenAiUsage::deserialize(u).ok()) - .map(|u| Usage::from(&u)); - crate::api::NonStreamingResponse { + .map(|u| Usage::from(&u)) + .filter(|u| u.input_tokens > 0 || u.output_tokens > 0); + Ok(crate::api::NonStreamingResponse { message: Message::new(Role::Assistant, parts), stop_reason, usage, - } + }) } /// Send a POST request to the chat-completions endpoint. @@ -338,7 +350,7 @@ impl ApiClient for OpenAiClient { let resp = super::read_bounded_body(resp).await?; let raw = serde_json::from_slice::(&resp) .map_err(|e| ApiError::http(e.to_string()))?; - Ok(Self::build_response(&raw)) + Self::build_response(&raw) }) } @@ -412,7 +424,7 @@ impl ApiClient for OpenAiClient { let resp = super::read_bounded_body(resp).await?; let raw = serde_json::from_slice::(&resp) .map_err(|e| ApiError::http(e.to_string()))?; - Ok(Self::build_response(&raw)) + Self::build_response(&raw) }) } } @@ -1259,7 +1271,10 @@ impl StreamEmitter { } if let Some(usage) = &chunk.usage { - self.pending_usage = Some(Usage::from(usage)); + let typed = Usage::from(usage); + if typed.input_tokens > 0 || typed.output_tokens > 0 { + self.pending_usage = Some(typed); + } } if let Some(choice) = chunk.choices.first() { @@ -2539,7 +2554,7 @@ mod tests { "finish_reason": "stop" }] }); - let response = OpenAiClient::build_response(&raw); + let response = OpenAiClient::build_response(&raw).unwrap(); assert_eq!(response.message.role, Role::Assistant); assert_eq!(response.message.text_content(), "hello"); assert_eq!(response.stop_reason, StreamStopReason::EndTurn); @@ -2559,7 +2574,7 @@ mod tests { "finish_reason": "tool_calls" }] }); - let response = OpenAiClient::build_response(&raw); + let response = OpenAiClient::build_response(&raw).unwrap(); assert_eq!(response.message.parts.len(), 1); match &response.message.parts[0] { MessagePart::ToolCall { id, name, input } => { @@ -2580,7 +2595,7 @@ mod tests { "finish_reason": "length" }] }); - let response = OpenAiClient::build_response(&raw); + let response = OpenAiClient::build_response(&raw).unwrap(); assert_eq!(response.stop_reason, StreamStopReason::MaxTokens); } @@ -2593,7 +2608,7 @@ mod tests { }], "usage": {"prompt_tokens": 42, "completion_tokens": 7} }); - let response = OpenAiClient::build_response(&raw); + let response = OpenAiClient::build_response(&raw).unwrap(); assert_eq!(response.usage.expect("usage").input_tokens, 42); assert_eq!(response.usage.expect("usage").output_tokens, 7); assert_eq!(response.usage.expect("usage").total_tokens(), 49); @@ -2607,10 +2622,26 @@ mod tests { "finish_reason": "stop" }] }); - let response = OpenAiClient::build_response(&raw); + let response = OpenAiClient::build_response(&raw).unwrap(); assert!(response.usage.is_none()); } + #[test] + fn build_response_zero_usage_collapses_to_none() { + let raw = serde_json::json!({ + "choices": [{ + "message": {"content": "hi"}, + "finish_reason": "stop" + }], + "usage": {"prompt_tokens": 0, "completion_tokens": 0} + }); + let response = OpenAiClient::build_response(&raw).unwrap(); + assert!( + response.usage.is_none(), + "all-zero usage must collapse to None" + ); + } + #[test] fn build_response_text_and_tool_calls_combined() { let raw = serde_json::json!({ @@ -2625,7 +2656,7 @@ mod tests { "finish_reason": "tool_calls" }] }); - let response = OpenAiClient::build_response(&raw); + let response = OpenAiClient::build_response(&raw).unwrap(); assert_eq!(response.message.parts.len(), 2); assert!(response.message.parts[0].is_text()); assert!(response.message.parts[1].is_tool_call()); @@ -2646,7 +2677,7 @@ mod tests { "finish_reason": "tool_calls" }] }); - let response = OpenAiClient::build_response(&raw); + let response = OpenAiClient::build_response(&raw).unwrap(); assert_eq!(response.message.parts.len(), 2); match &response.message.parts[0] { MessagePart::ToolCall { id, name, .. } => { @@ -2665,7 +2696,7 @@ mod tests { } #[test] - fn build_response_malformed_arguments_defaults_to_empty_object() { + fn build_response_malformed_arguments_returns_error() { let raw = serde_json::json!({ "choices": [{ "message": { @@ -2678,7 +2709,51 @@ mod tests { "finish_reason": "tool_calls" }] }); - let response = OpenAiClient::build_response(&raw); + let result = OpenAiClient::build_response(&raw); + assert!( + result.is_err(), + "malformed non-empty arguments must surface as an error, not silently default to {{}}" + ); + } + + #[test] + fn build_response_empty_arguments_defaults_to_empty_object() { + let raw = serde_json::json!({ + "choices": [{ + "message": { + "content": null, + "tool_calls": [{ + "id": "call_1", + "function": {"name": "search", "arguments": ""} + }] + }, + "finish_reason": "tool_calls" + }] + }); + let response = OpenAiClient::build_response(&raw).unwrap(); + match &response.message.parts[0] { + MessagePart::ToolCall { input, .. } => { + assert_eq!(input, &serde_json::json!({})); + } + other => panic!("expected ToolCall, got {other:?}"), + } + } + + #[test] + fn build_response_missing_arguments_defaults_to_empty_object() { + let raw = serde_json::json!({ + "choices": [{ + "message": { + "content": null, + "tool_calls": [{ + "id": "call_1", + "function": {"name": "search"} + }] + }, + "finish_reason": "tool_calls" + }] + }); + let response = OpenAiClient::build_response(&raw).unwrap(); match &response.message.parts[0] { MessagePart::ToolCall { input, .. } => { assert_eq!(input, &serde_json::json!({})); @@ -2690,7 +2765,7 @@ mod tests { #[test] fn build_response_missing_choices_yields_empty_message() { let raw = serde_json::json!({}); - let response = OpenAiClient::build_response(&raw); + let response = OpenAiClient::build_response(&raw).unwrap(); assert!(response.message.parts.is_empty()); assert_eq!(response.stop_reason, StreamStopReason::EndTurn); } @@ -2703,7 +2778,7 @@ mod tests { "finish_reason": "content_filter" }] }); - let response = OpenAiClient::build_response(&raw); + let response = OpenAiClient::build_response(&raw).unwrap(); assert_eq!(response.stop_reason, StreamStopReason::EndTurn); } From 08c5c21a1e08d0ad3da925b204d8a9cb2a530259 Mon Sep 17 00:00:00 2001 From: Iurii Bobrykov Date: Sat, 1 Aug 2026 19:15:48 +1200 Subject: [PATCH 20/20] chore: update CHANGELOG to reflect v1>v2 changes --- CHANGELOG.md | 822 +++++++++------------------------------------------ 1 file changed, 138 insertions(+), 684 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e50a3e..7adefde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,704 +7,158 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/2.0.0. ## [Unreleased] +## [0.2.0] - 2026-08-01 + ### Added -- `engine::machine::LoopMachine` and supporting types — a sans-IO, serializable - state machine that owns every agent-loop decision (turn counting, max-turn - enforcement, tool-call validity, stop-reason routing, compaction trigger, - history, cancellation). `Serialize + Deserialize`, with no `async`, no - `tokio`, and no `ApiClient` in its surface. Includes `RunConfig`, - `MachineStep` (`CallLLM`/`CallTools`/`Compact`/`Done`), `ModelTurn`, - `PendingToolCall`, `MachineOutcome`, and `MachineState`. `BareLoop` now drives - a `LoopMachine` internally (`run()` is a `match machine.next_step()` loop); the - machine is exposed via `BareLoop::machine()` / `into_machine()` / - `from_machine()` for inspection and serialize-and-resume. -- `LoopMachine::inject(message)` — add an arbitrary message to the machine's - history (host steering, or `ContextContributor` goal re-injection). -- `Session`/`Run`/`Turn`/`RunResult` lifetime types (`engine::core`) — - the Session ⊃ [Run ⊃ [Turn]] hierarchy: one `Session` spans the process, - one `Run` per `run()` prompt, one `Turn` per loop iteration. `Session` - derives per-session totals (`total_turns`/`total_duration`/ - `total_input_tokens`/`total_output_tokens`) from its run list. `Run` - is the result of a `run()`; `Run::turn_count()`/`duration()`/`total_tokens()`. -- `SessionConfig` (`config`) — the session-scoped config slice (`session_id`, - `system_prompt`, `context_window`) with `with_*` builders, replacing the - session fields that lived on the old `LoopConfig`. -- `LoopError` now derives `Serialize`, `Deserialize`, `PartialEq`, and `Eq`. -- `compact::types::CompactReason` now derives `Serialize` and `Deserialize`. -- `DeltaPart::Thinking { text }` variant + `on_thinking_delta` observer event - (`ThinkingDeltaContext`): reasoning-model tokens (Claude extended-thinking, - DeepSeek-R1, OpenAI o-series, Gemini 2.5+) are now routed as their own - stream kind instead of being dropped or misrouted into text. Stream-only — - reasoning is not accumulated into the `Message` and does not reach - `ResponseContext.text`; consume it via `on_thinking_delta` or the raw - `IndexedDelta(Thinking)` stream event. Anthropic parses `thinking_delta` - (and emits an empty Thinking delta for `redacted_thinking`); OpenAI parses - `reasoning_content` (aliased to `reasoning`); Gemini parses per-part - `thought: true` flags. An empty `delta` signals redacted reasoning (render - a placeholder). For Gemini, `GeminiClientBuilder::include_thoughts(true)` - opts into `generationConfig.thinkingConfig.includeThoughts` on the request - side — opt-in because the Gemini API rejects `thinkingConfig` with - `400 INVALID_ARGUMENT` on non-reasoning models; defaults to `false`. -- `DisplayHint` advisory rendering hint on `ToolOutput` (`with_hint()` builder), - threaded through `ToolDispatchResult` and `ToolPostContext` so presentation - layers (TUI, headless console) can render a tool result by the tool's own - declaration instead of inferring the strategy from the tool name. Six - variants: `Text`, `Diff`, `Json`, `Code { language }`, `Suppress`, - `Markdown`. Advisory only — compaction, loop-detection hashing, and loop - semantics are unaffected (the hint terminates at the observer context and - never enters the message model). -- `ConstrainedProfile`, `FrontierProfile`, and `GoalReminder` (`presets` - module): a named small-model-tuned runtime profile and its frontier opt-out - counterpart. `ConstrainedProfile::apply(&mut loop_)` wires the small-model - middleware stack (verify with `NoopVerifier`, memoize with - `NoopPathExtractor`, output cap) and registers a `GoalReminder` contributor; - `loop_config()` returns a tighter `LoopConfig` (120k window, 100 turns) and - `request_options()` returns `tool_constraint: Strict`. Compose the pieces - individually via `pipeline_builder()` / `loop_config()` / `request_options()`. -- `NoopVerifier` (`middleware::verify`): a `Verifier` that always passes, - co-located with the `Verifier` trait. Default verifier for - `ConstrainedProfile`; swap in a real build/lint step when available. -- `NoopPathExtractor` (`middleware::memoize`): a `PathExtractor` that extracts - no paths, disabling path-based cache invalidation (TTL-only caching). - Default extractor for `ConstrainedProfile`. -- `BareLoop::set_request_options(opts)` builder: set the per-turn - `RequestOptions` (carrying `tool_constraint`) applied to every provider call. - Default is `RequestOptions::default()` (no constraint), reproducing prior - behavior. -- `StreamHandler::passthrough()` constructor + `passthrough_default()` — a - no-resilience handler (no retries, no timeouts, no fallback) used as the - engine default when no handler is configured. -- `HandlerEvent` enum (`stream::handler`): events yielded by the new - stream-based `StreamHandler::stream_turn`. Variants: `Stream(StreamEvent)` - for raw provider events, `AttemptReset` on retry, `Fallback { message, - stop_reason }` on non-streaming fallback. -- `StreamRequest` struct (`api`): bundles `(messages, system, tools)` into a - single parameter for `ApiClient` methods. Replaces the positional - `(Vec, Option, Option>)` parameter lists on - `stream_messages`, `create_message`, and their `_with_options` variants. - Builders: `new`, `with_system`, `with_tools` (both take `Option`). -- `GeminiClientBuilder::include_thoughts(bool)` builder: opt into Gemini's - `thinkingConfig.includeThoughts` for reasoning-capable models (2.5 Pro/Flash, - Gemini 3). Defaults to `false` — the Gemini API rejects `thinkingConfig` - with `400 INVALID_ARGUMENT` on non-reasoning models, so the caller must opt - in once they know their model supports thinking. -- HTTP connection-pool injection and tuning on all three provider builders - (`OpenAiClientBuilder`, `AnthropicClientBuilder`, `GeminiClientBuilder`): - `.http_client(reqwest::Client)` injects a shared client so multiple providers - can reuse one connection pool. `.pool_max_idle_per_host(usize)`, - `.pool_idle_timeout(Duration)`, and `.tcp_keepalive(Duration)` expose the - underlying `reqwest` pool knobs (default to reqwest's built-in defaults when - unset). When an injected client is used, these knobs and `.timeout()` / - `.connect_timeout()` are ignored — configure them on the injected client. -- `ContextContributor` trait and `ContributorContext<'a>` (`engine::contributor` - module): a write-side hook at the turn boundary. Implementors return an - optional `Message` that the loop appends to the conversation before the next - model call. Register on `BareLoop` via `add_contributor`; with no contributors - registered, the loop behaves identically to before (the turn-top consultation - is a single cheap branch). -- `Role::System` variant on `message::Role`. Serialized as `"system"`. Used for - framework-injected context such as a turn-boundary reminder. Providers map it - to their native system representation: an inline `{role: "system"}` message on - OpenAI, or the top-level system field on Anthropic and Gemini (which do not - accept an inline system role mid-conversation). -- `ApiError::rate_limited(message, retry_after)` constructor for the structured - rate-limit carrier variant. -- `LoopError::RateLimitEscalation { attempts, retry_after }` variant, - recoverable, raised when the stream handler exhausts rate-limit retries on a - model and escalates to the circuit breaker. -- `StreamHandlerError::RateLimitEscalation { attempts, retry_after }` - variant. After `RateLimitConfig::fallback_after_retries` rate-limit retries, - `stream_turn` yields this as a stream error instead of looping - indefinitely or falling back to the same model's non-streaming endpoint. -- Rate-limit backoff sleeps are now clamped to the turn's `total_stream_timeout` - so a large `Retry-After` cannot overrun the turn budget. -- The engine routes `RateLimitEscalation` to - `FallbackManager::record_model_failure`, so a sustained rate limit on one - model trips the circuit breaker and subsequent turns route to the fallback - model. -- `TokenBucket` and `RateLimiter` (`stream::rate_limit`): a proactive - client-side token-bucket rate limiter, one bucket per provider `base_url`. - Attaches to `StreamHandler` via `with_rate_limiter`; gates each stream - attempt before it fires, with a `max_wait` ceiling that degrades to reactive - (proceed, risk the 429) rather than hang. -- `ApiClient::base_url()` trait method (default `""`), overridden by the - OpenAI, Anthropic, and Gemini clients to expose their configured endpoint for - per-provider bucket keying. -- `ParallelMode` + `ParallelDispatchConfig` in `config.rs`: opt-in parallel - tool dispatch for independent, concurrency-safe calls within a single turn. - `LoopConfig` is now `#[non_exhaustive]`; the new `parallel_tool_dispatch` - field defaults to `Sequential` (v0.1.0 behaviour unchanged). -- `Tool::resource_key(&self, &Value) -> Option` trait method (default - `None`) for parallel-dispatch resource-conflict detection, plus the - `FnTool::with_resource_key` builder. -- `MockTool::with_delay(Duration)` builder for timing-sensitive tests. -- `StructuredOutput` trait, `ResponseFormat`, `RequestOptions`, and - `StructuredError` (`structured` module): request guaranteed-schema JSON - responses from the model. Includes a lenient JSON extraction helper (handles - markdown fences/prose prefixes) and a `request_structured::()` - convenience function. -- `ApiClient::stream_messages_with_options` and - `create_message_with_options` default methods (additive — existing impls - compile unchanged). `OpenAiClient`, `AnthropicClient`, and `GeminiClient` - override both to inject the schema (OpenAI via native `response_format`, - Anthropic via forced-tool tool-forcing, Gemini via `generationConfig` - `responseMimeType` + `responseSchema`). -- `ToolOutput::structured`, `structured_value()`, and - `structured_as::()` for typed tool results that round-trip through JSON. -- `ToolConstraint` enum (`structured` module, `#[non_exhaustive]`, - default `None`) and `RequestOptions::tool_constraint` field + builder for - constraining the model's tool-call output to the registered tool schemas. - `ToolConstraint::Strict` makes malformed tool calls structurally impossible - via the provider's native strict-tool mode; `ToolConstraint::Grammar` - (requires the new `grammar` feature) compiles the schemas into a grammar - for a grammar-aware sampler (vLLM `guided_json`). -- `ToolGrammarProvider` trait and `JsonSchemaGrammar` default impl - (`provider::grammar` module, `grammar` feature): the extension point for - compiling a tool registry's schemas into a sampler grammar. -- `grammar` feature flag (depends on `providers`): opt-in grammar / sampler - support for the `Grammar` mode of `ToolConstraint`. -- `LlmReflector` (`reflection::llm` module): a `Reflector` that asks the - model to classify failed tool calls and suggest corrections via - `request_structured::`. First in-tree consumer of - `StructuredOutput`. Opt-in via `BareLoop::set_reflector`; the default - stays `NoopReflector`. Each analyzed failure triggers one model - round-trip (see its rustdoc for the latency/cost note). -- `impl StructuredOutput for FailureAnalysis` (`reflection` module) with a - hand-written JSON Schema covering the 5 fields and the nested - `CorrectionType` snake_case enum. -- `schema_validation` feature flag (pulls `jsonschema` as an optional - dependency): when enabled, `LlmReflector` validates the model's - `Correction::modified_input` against the failing tool's `input_schema` - and returns `ReflectionError::Internal` on a mismatch. When disabled, - validation is skipped. -- `VerifyMiddleware`, `Verifier` trait, and `VerifyResult` - (`middleware::verify` module): opt-in post-execution middleware that - runs a caller-supplied verifier after configured write-class tools and - appends the pass/fail + diagnostics to the `ToolOutput` so the next - turn sees it. The verifier impl (`cargo check`, `tsc`) is - domain-specific and supplied by the consumer; loopctl ships only the - trait and the middleware. -- `MemoizingMiddleware` and `PathExtractor` trait - (`middleware::memoize` module): opt-in middleware that caches - successful tool-call results keyed on `(tool_name, hash(canonical - input))` and returns cached output with a `[cached]` marker on hit. - Path-aware invalidation via the caller-supplied `PathExtractor` trait; - TTL-based expiry per `ttl_turns`. Default-off; only successful results - are cached. -- Fluent `with_*()` builder methods on `LoopConfig` (one per public field, - e.g. `with_model`, `with_max_turns`, `with_session_id`, - `with_parallel_tool_dispatch`). Each is `#[must_use]`, consuming, and - returns `Self` for chaining. Purely additive — `Default`-based and direct - struct-literal construction are unchanged and remain first-class. -- Consuming `with_*()` fluent builders on `BareLoop`, mirroring the existing - `&mut self` setters so a loop can be assembled as a chain off `BareLoop::new`: - `with_reflector`, `with_recovery_strategy`, `with_context_manager`, - `with_stream_handler`, `with_hook_executor` (`hooks` feature), - `with_health_registry` (`tool_health` feature), `with_pipeline` (returns - `Result` — chain with `?`), `with_observer`, - `with_text_streamer`, `with_contributor`, `with_request_options`. The - original `set_*`/`register_*` setters are unchanged and remain available. -- `CancelSignal::reset()` — re-arm a fired signal by swapping in a fresh - underlying `CancellationToken`. Required because the token is one-shot - by design; once fired it cannot be revived. All clones of an - `Arc` observe the new token, so a handle returned by - `BareLoop::cancel_signal()` keeps working across resets. `BareLoop` - calls this in `finalize()` so each `run()` starts with a clean signal. -- `StreamHandler::with_timeout_config(config)` and - `with_retry_config(config)` — independent, self-validating builders for - the streaming timeout and retry configs (mirroring the existing - `with_rate_limit_config`). Each validates its config and falls back to - the default on an invalid value, replacing the coupled - `with_config(timeout, retry)` builder. -- `StreamRetryConfig::jittered_base_delay(attempt)` — the exponential - backoff with [`jitter_factor`](crate::stream::handler::StreamRetryConfig::jitter_factor) - applied, used by `StreamHandler` between transport-retry attempts. The - jitter is deterministic (derived from the attempt number via a - shift-based mix) so the same attempt always yields the same delay while - successive attempts spread their backoffs — no randomness dependency. - `base_delay` (the raw exponential core) remains public. -- **`LoopMemory` is now wired into the framework.** The trait was previously - exported and documented but consumed by nothing. The engine now: - - **Stores** a trajectory entry after each successful tool call (tool name, - input, result) via `LoopManagers::memory()`. - - **Retrieves** up to 3 relevant entries before each turn and injects them - as a system message into the conversation. - - **Consolidates** (prunes) the store at the end of a successful run. - The `LoopMemory` trait is now object-safe (`Pin>` returns) - so the store can live behind `Arc` on - `LoopManagers`. Configure via `BareLoop::set_memory` / - `with_memory`, or `LoopManagers::set_memory` / `with_memory`. - A `RememberCapable` capability trait exposes it to trait-bounded code. - All three hooks are no-ops when no memory store is attached. +- **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::()`. 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`. ### Changed -- **Breaking (`create_message` returns a typed response):** - `ApiClient::create_message` and `ApiClient::create_message_with_options` - now return `Result` instead of - `Result`. `NonStreamingResponse` carries the - fully assembled `message: Message`, a typed `stop_reason: - StreamStopReason`, and the token `usage: Usage` — the typed counterpart to - a streamed response. Each provider builds this from its own native JSON, so - no provider-specific parsing remains at the call site (the handler's - `fallback_non_streaming` collapsed from a 30-line JSON parser to a single - field access). Token usage is now extracted from each provider's native - usage field (`usage.prompt_tokens`/`completion_tokens` on OpenAI, - `usage.input_tokens`/`output_tokens` on Anthropic, - `usageMetadata.promptTokenCount`/`candidatesTokenCount`/`thoughtsTokenCount` - on Gemini), and threaded through `HandlerEvent::Fallback` so the fallback - path reports real token counts instead of `None`. - Migration: read `.message`, `.stop_reason`, and `.usage` from the returned - struct instead of indexing into the JSON value. -- **Breaking (`extract_structured` takes `&Message`):** - `ApiClient::extract_structured` now takes `&Message` instead of - `&serde_json::Value` and has a default implementation that derives the - payload from the message (first tool-call `input`, else text lenient-parsed - as JSON). The per-provider overrides (`OpenAiClient`, `AnthropicClient`, - `GeminiClient`) and the shared `extract_structured_from_normalized` helper - are removed — the single default works for every provider because the - provider-specific envelope is gone by the time a typed `Message` exists. - Migration: if you override `extract_structured`, change the parameter from - `&serde_json::Value` to `&Message`; most overrides can be deleted in favor - of the default. -- `StreamStopReason::from_api_str` now accepts `"tool_use"` as an alias for - `"tool_call"`. Anthropic reports a tool-invocation stop reason as - `"tool_use"` (while OpenAI uses `"tool_calls"`); both now map to `ToolCall`. - This fixes a pre-existing bug where Anthropic tool-call responses were - misclassified as `EndTurn` on both the streaming and non-streaming paths. -- `Usage` now derives `PartialEq` and `Eq`. -- **OpenAI streaming usage:** the OpenAI streaming client now sets - `stream_options.include_usage` on streaming requests and captures the token - counts from the final usage chunk. Previously the streaming path always - reported `usage: None` (it discarded usage), so a streamed turn reported zero - tokens while a non-streaming fallback reported real counts — the successful - path reported less than the fallback. The `StreamEmitter` now defers the - `MessageDelta` until either the usage chunk arrives or the stream ends, - emitting the stop reason and usage together. All three providers (OpenAI, - Anthropic, Gemini) now report usage on both streaming and non-streaming paths. - `stream_options.include_usage` is opt-in via - `OpenAiClientBuilder::with_stream_usage(false)` for OpenAI-compatible servers - that reject the parameter; the `ollama()` constructor disables it - automatically for compatibility with older Ollama versions. -- **Breaking (`NonStreamingResponse.usage` is now `Option`):** the field - changed from `Usage` to `Option`, symmetric with the streaming path's - `Option`. This lets callers distinguish "provider reported zero tokens" - from "provider omitted usage." `None` means the provider did not include a - usage object in its response. Migration: unwrap with `.unwrap_or_default()` - or pattern-match on `Option`. -- **Breaking (`MessagePart::ToolResult` gains a `name` field):** tool results - now carry the tool's function name alongside the `call_id`. Required by Gemini - (and other providers) that correlate tool responses by function name in - `functionResponse`, not just by call id. `MessagePart::tool_result()` gains a - `name` parameter (second argument, after `call_id`). - Migration: add the tool name as the second argument to every - `MessagePart::tool_result()` call. -- **Gemini tool-call ids:** the Gemini provider now parses `functionCall.id` - (Gemini 3 returns a unique id per call) and echoes it in - `functionResponse.id`, fixing tool-response correlation for parallel calls. - The `functionResponse` serialization now sends both `name` (the function name) - and `id` (the call id) instead of putting the call id in `name`. - Migration: none — serialization is automatic. -- **OpenAI usage unification:** the non-streaming `build_response` path now uses - the same `OpenAiUsage` serde struct as the streaming path, deleting the manual - `extract_usage` helper. Both paths share one deserialization strategy. -- **SSE invalid-UTF-8 handling:** `SseReader::take_line` now returns - `Result, ApiError>` and surfaces genuinely invalid UTF-8 as a - protocol error instead of silently replacing bytes with `U+FFFD` via - `String::from_utf8_lossy`. Callers (`next_openai_data`, `next_anthropic_data`, - `next_gemini_data`) propagate the error via `?`. -- **`with_tcp_nodelay` setter:** all three provider builders - (`OpenAiClientBuilder`, `AnthropicClientBuilder`, `GeminiClientBuilder`) and - `HttpClientConfig` now expose `with_tcp_nodelay(bool)`. The field was - previously hardcoded to `true` with no escape hatch. Default remains `true`. -- **Breaking (session→run lifecycle rename):** the observer and hook events - formerly named `on_session_start` / `on_session_end` are renamed to - `on_run_start` / `on_run_end`. These events fire once per `run()` call and - carry per-run data (turn count, per-run duration, per-run tokens), so the - new names match their actual semantics. The Session ⊃ Run ⊃ Turn hierarchy - is unchanged: a Session spans the agent's lifetime, a Run is one `run()` - call, a Turn is one loop iteration. Affected APIs (old → new): - `LoopObserver::on_session_start` → `on_run_start`, - `LoopObserver::on_session_end` → `on_run_end`, - `observer::SessionStartContext` → `observer::RunStartContext`, - `observer::SessionEndContext` → `observer::RunEndContext`, - `Hook::on_session_start` → `on_run_start`, - `Hook::on_session_end` → `on_run_end`, - `hooks::context::SessionStartContext` → `hooks::context::RunStartContext`, - `hooks::context::SessionEndContext` → `hooks::context::RunEndContext`, - `hooks::context::SessionEndReason` → `hooks::context::RunEndReason`, - `HookExecutor::notify_session_start` → `notify_run_start`, - `HookExecutor::notify_session_end` → `notify_run_end`, - `HookExecutor::notify_session_start_async` → `notify_run_start_async`, - `HookExecutor::notify_session_end_async` → `notify_run_end_async`, - `ObserverHost::on_session_start` → `on_run_start`, - `ObserverHost::on_session_end` → `on_run_end`. - Migration: rename the method/trait impl in every `impl LoopObserver` and - `impl Hook`; rename every `SessionStartContext` / `SessionEndContext` / - `SessionEndReason` reference. The `session_id` field on the renamed context - types is unchanged (a run belongs to a session). -- **Breaking (per-run manager reset):** `LoopManagers::reset_all` is no - longer called automatically from `run()`. Previously it fired on every - `run()` call, wiping session-scoped manager state (fallback circuit - breaker, loop detection, observers) between runs. On a fresh session - the managers are already in their default state, so the call was a - no-op on the first run and a correctness bug on subsequent runs (it - discarded accumulated circuit-breaker and detection state). To - reinitialise mid-session, call `managers.reset_all()` explicitly. -- `RunConfig` gains a `reset_managers: bool` field (default `false`). Set it - to `true` when a run is logically independent from the previous one and you - want fresh circuit-breaker / detection / observer state for that run. This - replaces the removed automatic `reset_all` with explicit, per-run control. - Because `RunConfig` is `#[non_exhaustive]`, existing code that constructs it - via `Default::default()` or `RunConfig { .. }` continues to work unchanged. -- `on_run_start` (the renamed `on_session_start`) now fires at the start of - **every** `run()` call, matching `on_run_end` which already fired on every - `run()` call. Previously it fired only once (on the first `run()`), creating - an asymmetry where a multi-run session saw 1 start event but N end events. - The session-scoped bookkeeping (`session_start` timestamp, `reset_all`) is - unaffected — it still runs only once, on the first `run()`. - -- **Breaking (machine-driven engine):** `BareLoop::run()` is now a - `match machine.next_step()` loop driving a `LoopMachine`. The machine owns the - conversation history and every loop decision (turn count, max-turn, tool-call - validity, compaction trigger, cancellation); the driver owns IO (LLM call, - tool dispatch, compaction execution) and fires observers from the match-arms. - Observer event ordering is unchanged (pinned by golden tests). The - conversation is owned by the machine — read it via `BareLoop::conversation()` - (delegates to the machine) or `BareLoop::machine().history()`. -- **Breaking (Session/Run lifetime model):** the agent-loop lifetime is now - explicit. `LoopConfig` is **removed**; construction splits into - `SessionConfig` (session-scoped: `session_id`, `system_prompt`, - `context_window`) and `engine::RunConfig` (per-run: turn/token budgets, - compaction policy, dispatch mode). `SessionResult` is **removed** and unified - with the new `Run`/`Run` (`engine::core`): the per-run accumulator - is `Run`-shaped (`turns: Vec`, `error: Option`). The `model` - field is gone from config entirely — it lives on the `ApiClient` - (`ApiClient::model` / `set_model`). Migration: build `BareLoop::new` with a - `SessionConfig`; read `session_id`/`system_prompt`/`context_window` from - `SessionConfig`, run budgets from `RunConfig`, the model from the client. -- **Breaking (`Loop::run` signature + `initialize` removed):** `Loop::run` now - takes the per-run config — `run(&mut self, user_input: &str, run_config: - &RunConfig)` — and returns `Result`. Session - initialization happens once at construction (not per `run()`); each `run()` - receives a fresh `RunConfig`. `Loop::initialize` and `Loop::config()` are - removed. Migration: pass `&RunConfig::default()` (or a specific run config) - as the second `run()` argument; move any `initialize` setup into - construction. -- **Breaking (compaction thresholds → percentages):** the compaction trigger - threshold and compaction-target fraction are now `u8` percentages (0–100; - 100 = 100%) instead of `f64` fractions. Affected APIs: - `ContextManager::with_threshold(u8)` and `with_compact_target_pct(u8)` - (were `f64`); `ContextManager::threshold() -> u8` and - `compact_target_pct() -> u8` (were `f64`); - `SessionConfig::with_compact_threshold(u8)` and the `compact_threshold` field - (were `f64`). The default is `80` (was `0.80`); the clamp range is - `[1, 100]` (was `[0.1, 1.0]`). Migration: multiply existing `f64` - values by 100 and round — `0.80 → 80`, `0.50 → 50`, `0.70 → 70`. -- **Breaking (renames):** consuming builder methods that return `Self` are now - uniformly prefixed `with_`, matching the crate-wide convention. The old - no-prefix names are removed. Affected types and methods (old → new): - `SessionResultBuilder` (`session_id`/`total_turns`/`input_tokens`/ - `output_tokens`/`total_duration`/`tool_calls`/`success`/`final_output`/ - `error` → `with_*`); `ModelSwitch` (`context_window`, `max_tokens` → - `with_*`); `StreamRequest` (`system`, `system_opt`, `tools`, `tools_opt` → - `with_*`); `RequestOptions` (`response_format`, `tool_constraint` → `with_*`); - `UnixShieldBuilder` (`warn_threshold`, `block_threshold`, `pattern`, - `combination_rule` → `with_*`); `AutoCommitConfigBuilder` (`enabled`, - `message_template`, `auto_push`, `files` → `with_*`); `ToolPipelineBuilder` - (`core` → `with_core`; the middleware accumulators `with` and `with_arc` are - renamed to `with_middleware` and `with_middleware_arc` so they no longer read - ambiguously next to `with_core`). Migration: add the `with_` prefix at each - call site. -- `ToolPipelineBuilder::build` now distinguishes its two failure cases: - `PipelineError::Empty` when neither middleware nor a core was added (the - builder is untouched), and `PipelineError::MissingCore` when at least one - middleware was added but no core registry was set. Previously both cases - returned `MissingCore`, and `Empty` was unreachable. The `Empty` and - `MissingCore` variants now carry multiline rustdoc explaining each condition. -- **Breaking (optional-field builders unified):** builder methods that set an - `Option` field now uniformly take `Option` and drop the `_opt` suffix — - the parameter type already conveys "optional," so the name should not. - `StreamRequest` loses `with_system_opt`/`with_tools_opt`; `with_system` and - `with_tools` now take `Option`/`Option>` (pass - `Some(…)` to set, `None` to clear). `LoopConfig::with_system_prompt` changes - from `impl Into` to `Option`, so it can now clear the - override with `None` (previously it could only set). `AttemptRecord::with_reason` - and `Operation::with_result_hash` already followed this shape and are - unchanged. Migration: wrap existing literal/value arguments in `Some(…)` (or - rename `with_*_opt` → `with_*`). -- **Breaking:** `stream::DeltaPart` is now `#[non_exhaustive]`. Every - exhaustive `match` on `DeltaPart` in downstream code must add a `_ =>` arm - (or a `Thinking =>` arm for the new variant). Same-crate matches are - unaffected. Future variant additions (e.g. `Image`, `Audio`) will arrive - non-breaking. -- **Breaking:** `ApiClient::stream_messages`, `stream_messages_with_options`, - `create_message`, and `create_message_with_options` now take a single - `StreamRequest` parameter instead of positional `(messages, system, tools)`. - Every `impl ApiClient` must update its signatures. -- **Breaking:** `StreamHandler::stream_turn` now returns - `impl Stream>` instead of - `Future>`. Callers must drive the stream and - accumulate events themselves. The engine's `stream_turn` does this internally - and fires observer callbacks (`on_text_delta`, `on_thinking_delta`, - `text_streamer`) per event — configuring a `StreamHandler` for resilience no - longer drops real-time observability. -- **Breaking:** `StreamCapable::stream_handler` now returns `&StreamHandler` - (not `Option<&StreamHandler>`). When no handler is configured, returns a - shared `StreamHandler::passthrough_default()` (no-resilience default). -- **Breaking:** `StreamHandlerError::RateLimitEscalation` lost its `prior` - field. The variant is now `{ attempts, retry_after }`. -- **Breaking:** `StreamHandler::stream_turn` now takes `options: - RequestOptions` as an explicit parameter. `StreamHandler::with_request_options` - is removed — use `BareLoop::set_request_options` instead. -- **Breaking:** All three provider builders (`OpenAiClientBuilder`, - `AnthropicClientBuilder`, `GeminiClientBuilder`) now use `with_` prefix on - consuming builder methods (e.g. `.with_api_key()`, `.with_model()`, - `.with_timeout()`, `.with_http_client()`). The old no-prefix names - (`.api_key()`, `.model()`, etc.) are removed. -- **Breaking:** `ToolOutput`, `ToolDispatchResult`, and `ToolPostContext` are - now `#[non_exhaustive]`, matching `DisplayHint`. Downstream code that - constructs these via struct literal must switch to the named constructors - (`ToolOutput::text`/`success`/`error`/`error_text`, `ToolDispatchResult::ok`/ - `err`/`from_tool_output`/`from_result`, or the `From` impl) or - add `..Default::default()` where a `Default` exists. Same-crate construction - is unaffected. Future fields on these types will now arrive non-breaking. -- The engine now calls `ApiClient::stream_messages_with_options` instead of - `stream_messages` on every turn (both the inline-streaming path in - `engine::bare::stream` and the `StreamHandler` path), passing the loop's - `RequestOptions`. This is additive — the default `RequestOptions::default()` - has no `response_format` and `tool_constraint: None`, which reproduces the - prior behavior exactly. Custom `ApiClient` impls that do not override - `stream_messages_with_options` inherit the trait default (which delegates to - `stream_messages` when `response_format` is `None`), so they continue to - work; a `tool_constraint` other than `None` set via `set_request_options` - only takes effect on clients that override `_with_options` (the built-in - OpenAI, Anthropic, and Gemini clients do). -- **Breaking:** `message::Role` gains a `System` variant. Every exhaustive - `match` on `Role` in downstream code must add a `System =>` arm (or a `_ =>` - wildcard). Migration: add `Role::System => /* your mapping */` to each match, - or switch to a wildcard arm. The three built-in providers are already - updated; the Anthropic and Gemini serializers fold any inline `Role::System` - messages into the top-level system request field (`system` / `systemInstruction`) - rather than emitting them inline, because those providers accept system content - only as a top-level field. When both a caller-supplied system prompt and inline - `Role::System` messages are present, they are joined (caller prompt first, - folded text appended, newline-separated). OpenAI emits `Role::System` as an - inline `{role: "system"}` message, its native form. -- **Breaking:** `Reflector::analyze` gains a new `tool_schema: - Option<&ToolSchema>` parameter between `tool_input` and `context`. The - engine's call site now resolves the failing tool's schema from the - registry (passing `None` when the tool isn't found). Every `Reflector` - impl must add the new parameter; `NoopReflector` and the trait-doc - example have been updated. - Migration: add `_tool_schema: Option<&loopctl::tool::ToolSchema>` to - your `analyze` signature. Ignore it if your reflector does not validate - suggested corrections; otherwise use it to validate `modified_input` - before returning the analysis. -- `OpenAiClient`, `AnthropicClient`, and `GeminiClient` now honor - `RequestOptions::tool_constraint`. Under `Strict`, each tool's schema is - tightened (recursive `additionalProperties: false` and full `required`); - OpenAI additionally sets `strict: true` on each `function` entry. Under - `Grammar`, the OpenAI client injects `guided_json` for vLLM-style - grammar-aware samplers. Default `ToolConstraint::None` reproduces prior - behaviour exactly; when `response_format` is also set, it wins and - `tool_constraint` is ignored. -- Removed `parking_lot` dependency entirely. All `parking_lot::Mutex` - usages migrated to `std::sync::Mutex` with the - `.unwrap_or_else(std::sync::PoisonError::into_inner)` recovery pattern. - The crate now uses a single mutex family with no external lock - dependency. -- MSRV bumped from 1.85 to 1.94. The crate uses let-chain syntax - (`if x && let Some(y) = ...`) which stabilized in Rust 1.88. -- Both sequential and parallel tool dispatch now check the cancel signal - between calls. Previously, a Ctrl-C during a multi-tool batch was only - honored at the next turn boundary; now it aborts the remaining calls in the - batch. -- Internally-built `reqwest::Client`s now set `tcp_nodelay(true)` by default. - SSE streaming emits many small packets; disabling Nagle's algorithm reduces - per-delta latency. No correctness impact. -- `FallbackManager`'s circuit-breaker state is now unified behind a single - `Mutex`. Previously it was split across four independent - `Relaxed` atomics (`fallback_state`, `consecutive_failures`, - `primary_success_count`, `fallback_activated`) plus a `Mutex`, - which left a TOCTOU window between the state read and the - transition/counter-reset. The read-decide-transition in `record_failure` / - `record_success` now runs while holding the one lock, so the race is closed - by construction. Internal refactor — no public-API change beyond the method - merges noted under `### Removed`. +- **Breaking (`create_message` returns typed `NonStreamingResponse`):** no longer + returns raw `serde_json::Value`. The struct carries `message: Message`, + `stop_reason: StreamStopReason`, `usage: Option`. 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`. `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` builders take `Option` (no `_opt` suffix). Migration: add prefix, + wrap literals in `Some(...)`. +- **Breaking (`StreamHandler::stream_turn`):** returns `impl Stream>` 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`):** 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. ### Removed -- `StreamHandler::with_config(timeout, retry)` — set each config - independently via - [`with_timeout_config`](crate::stream::handler::StreamHandler::with_timeout_config) - and - [`with_retry_config`](crate::stream::handler::StreamHandler::with_retry_config) - instead. The coupled builder forced both configs to be passed in lockstep - even when only one changed; the two new builders each validate their own - config (an invalid value is logged and falls back to the default). -- `FallbackManager::record_api_failure` and - `FallbackManager::record_model_failure` — merged into a single - [`FallbackManager::record_failure(FailureKind)`](crate::fallback::FallbackManager::record_failure). - The two methods differed only in how a failure during - [`Recovering`](crate::fallback::FallbackState::Recovering) was treated: - a sustained rate-limit re-trips the breaker, a transient error leaves - the half-open probe in place. That distinction is now an explicit - [`FailureKind`](crate::fallback::FailureKind) argument (`RateLimit` vs - `Transient`) instead of two near-identical methods. Migration: pass - `FailureKind::RateLimit` where you called `record_model_failure`, - `FailureKind::Transient` where you called `record_api_failure`. -- `FallbackManager::record_failure` / `record_success` zero-body aliases - for `record_api_failure` / `record_model_success` — removed alongside - the merge. `record_success` is the new canonical name for the success - path (was `record_model_success`). -- `FallbackEntry` and `AttemptRecord` are now `pub(crate)` — they were - `pub` with no external users and ~400 lines of speculative accessors. - The `fallback_entry(name)` lookup method (which leaked the internal - type) is removed. These types were never part of the documented public - API surface; only `FallbackManager`, `FallbackState`, `FallbackConfig`, - and `FailureKind` are public in the `fallback` module now. -- `FallbackState::From` impl and the `= 0`/`= 1`/`= 2` discriminants - — dead weight from when state round-tripped through an `AtomicU8`. - State is now a plain field on an internal struct; no `u8` casting - remains. -- `Loop::process_turn` trait method and `BareLoop::run_turn_body` — the - machine-driven `run()` replaces the old per-turn execution path. The - `LoopMachine` is the new turn unit; drive it via `BareLoop::run()` (or - `LoopMachine::next_step()` directly for a custom driver). -- `StreamTurnResult` (the handler no longer accumulates; the engine assembles - the result from the event stream). -- `StreamHandler::with_request_options` builder (options now flow via - `stream_turn`'s parameter; configure via `BareLoop::set_request_options`). -- `StreamHandlerError::RateLimitEscalation.prior: StreamOutcome` field (never - read by any consumer). +- `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). ### Fixed -- Tool dispatch had three separate code paths (sequential, small-batch - parallel, and wave-parallel) each with its own copy of the recovery - loop, PRE/POST side-effect logic, and cancel handling. Two bugs came - from this split: the small-batch path called `dispatch_tool` directly - with no recovery (a flaky tool failed permanently where it would - recover elsewhere), and the parallel paths fired observer/detection/ - hook side-effects once on the final result while sequential fired - them per retry attempt — diverging loop-detection sensitivity, health - inputs, and observer event counts by dispatch mode. The entire - dispatch machinery is now one function (`execute_tool_call`) that owns - the full lifecycle (PRE → dispatch → POST → recovery loop) with - mid-flight cancel via `tokio::select!`. Sequential and parallel both - call it, so there is exactly one definition of "execute a tool call" - — no divergence is possible. Six functions (`parallel_pre_phase`, - `parallel_exec_phase`, `parallel_post_phase`, `parallel_run_remaining`, - `run_parallel_task`, `dispatch_tool_with_recovery`) collapsed into one - `execute_tool_call` + two thin dispatch loops. -- OpenAI streaming silently dropped every multi-chunk tool-call argument - fragment after the first, truncating the tool input JSON. The - deserialization structs declared `id` (on the tool-call delta) and - `name` (on the function object) as required `String` fields, but the - real OpenAI streaming protocol omits both on continuation chunks — - those carry only `index` and an `arguments` fragment. Serde rejected - those chunks with `missing field`, `OpenAiChunk::parse` returned - `None`, and the stream loop silently skipped them, leaving the - accumulated JSON incomplete. Both fields are now `Option` with - `#[serde(default)]`; the emitter latches `id` and `name` on the first - chunk for each `index` and ignores them on continuations. -- OpenAI streaming re-opened a tool-call part on every chunk carrying a - `function` field. Because every chunk (including argument-fragment - continuations) carries `function`, the `StreamEmitter` emitted - `PartStart` for each one, which wiped the downstream accumulator's - buffered JSON. The emitter now tracks each tool call's `index` and - emits `PartStart` exactly once per call. -- `StreamAccumulator` dropped parallel tool calls whose argument fragments - arrived interleaved (the shape OpenAI streams for - `parallel_tool_calls`). The accumulator tracked a single in-progress - part, so a second `PartStart` arriving before the first `PartStop` - overwrote the first call's buffer, and `IndexedDelta` fragments whose - `index` did not match the single current slot were silently dropped. - It now holds a `Vec` of open slots keyed by `index`, routes each delta - to the matching slot, and flushes slots in `PartStart` arrival order - (FIFO) on `PartStop`. Anthropic (strictly sequential) and Gemini - (atomic per-chunk tool calls) are unaffected. -- `BareLoop` was permanently dead after a single cancellation. Once - `cancel()` fired, the `CancelSignal` (a one-shot - `CancellationToken`) stayed cancelled forever, so every subsequent - `run()` returned `LoopError::Cancelled` immediately. `run()` now - re-arms the signal in `finalize()` — the single chokepoint every run - exit path passes through — so the next `run()` starts clean. A cancel - that arrives *before* a run still cancels that run (the signal is - cleared only after the run observes it and returns), preserving the - pre-run-cancel contract. -- A turn's tool results were split across multiple consecutive user - messages instead of merged into one. Unknown-tool results (preresolved - by the machine) each arrived as their own user `Message`, and the - dispatched known-tool results arrived as another, so the loop pushed - them into history as separate entries. `BareLoop::handle_call_tools` - now collects all tool-result parts for a turn — preresolved plus - dispatched — into a single user `Message` before feeding it to the - machine, so a turn with any mix of unknown and known tools produces - exactly one user message regardless of how the results were produced. - `build_tool_result_message` is renamed to `build_tool_result_parts` - and now returns `Vec` (it no longer wraps the parts in a - throwaway `Message`). -- `StreamHandler` silently accepted invalid timeout and retry configs. - `StreamTimeoutConfig::validate` and `StreamRetryConfig::validate` - existed but were never called in production — only - `RateLimitConfig::validate` was wired into its builder. A - misconfiguration that disabled timeouts or inverted the retry ceiling - (e.g. `total_stream_timeout` less than `initial_event_timeout`, zero - delays) was stored as-is. The new `with_timeout_config` and - `with_retry_config` builders validate each config and fall back to the - default on an invalid value. -- `StreamRetryConfig::jitter_factor` was validated but never applied — - the transport-retry backoff used the raw exponential delay with no - jitter, so concurrent retries landed on the same tick (thundering herd). - `jittered_base_delay` now applies the factor and is the delay - `StreamHandler` sleeps between attempts. -- The empty-stream fast-fail promised in the `max_consecutive_timeouts` - docs was never implemented: `next_event` applied the full threshold - unconditionally, so a stream that never produced a single event could - hang for `max_consecutive_timeouts` × `initial_event_timeout` (~20 min - with defaults) before failing. The lower threshold (`min(2, - max_consecutive_timeouts)`) is now applied when zero events have been - received, matching the documented behavior. -- `ToolHealthRegistry::is_tool_available` consumed the HalfOpen recovery - probe as a side effect of the read: it called - [`allow_request`](ToolCircuitBreaker::allow_request), which performs the - Open→HalfOpen transition, so a bare availability check (or - [`resolve_tool`](HealthRouter::resolve_tool)) wasted the single probe slot - and blocked the real dispatch that followed. It now uses pure-read - helpers (`would_allow_request` / `would_be_half_open`) that observe the - decision without the transition. -- The Anthropic provider hardcoded text content blocks to part index 0, - ignoring the server-supplied block index. Tool and thinking blocks used - the real index, so a response ordering like `[tool_use@0, text@1]` made - the text deltas collide with the tool-call's index 0. Text blocks now - track and emit at the server index (mirroring the tool/thinking lanes), - eliminating the collision. +- 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. ### Security -- The auto-commit hook's `GitExecutor::stage_files` ran `git add -A` - when called with an empty file list — the default configuration - (`AutoCommitConfig::files` defaults to `vec![]`, and a session with no - recorded modifications passes `None`, which falls back to that empty - list). This staged and committed the entire working tree on every - session end: unrelated user edits, scratch files, and secrets such as - `.env` and credentials. The empty-list branch now refuses with a - `GitExecutorError::GitError` instead of broadening scope; callers must - populate `AutoCommitConfig::files` or rely on the hook's per-session - modification tracking. Misconfiguration now fails loudly rather than - silently committing everything. -- The `MAX_RESPONSE_BODY` guard (10 MB) on non-streaming provider responses - fired *after* the body was fully materialized — every provider called - `resp.bytes().await` then checked the length, so a hostile or misbehaving - server returning a multi-GB body could exhaust memory before the guard - rejected it. A shared `read_bounded_body` now pre-checks `Content-Length` - (rejecting without reading a byte when it exceeds the cap) and reads - chunked-transfer responses with a running cap, so peak memory never - exceeds the limit by more than one chunk. Replaces the post-hoc - `check_response_body` across OpenAI, Anthropic, and Gemini. +- 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. ## [0.1.0] - 2025-07-01