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

Filter by extension

Filter by extension

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

### Fixed

- Corrected the `ParallelMode::Parallel` doc, which falsely claimed detection/observer side-effects "are not thread-safe" and fire "once on the final result only" in parallel mode. They are thread-safe (`DetectionManager` and the observer registry use `Mutex`/immutable-`Vec` interiors; `ToolHealthRegistry` uses atomics) and fire on every retry attempt in both modes, exactly as the code already does. No behavior change; the code matched the corrected doc all along.
- Fixed code-level doc contradictions: the `ApiClient` trait example showed `request: StreamRequest` (by-value) instead of `&StreamRequest` (matches the real trait), and `BareLoop::machine` was described as an "empty placeholder" rather than the real "empty machine (no history, no pending messages)".
- Reconciled the planning docs (ROADMAP, CONTEXT, ARCHITECTURE, README, DEPENDENCIES, DCH-DESIGN, the v0.2.0 release file) to the shipped 0.2.0 reality: status Planned→Shipped, `compact_threshold` u16→u8, `Loop::process_turn` soft-deprecated→removed, `LoopRuntime`/`LoopConfig`/`SessionResult`/`run_session` → their shipped replacements (`managers`/`SessionConfig`+`RunConfig`/`Run`+`Session`/`run`), MSRV 1.85→1.94, doctest count 303→286. Added a staleness banner to `LOOPCTL-DESIGN.md`.
- Restored the no-`#[allow(clippy::*)]` lint contract. Fixed: a private `TextStreamer` type alias, lossless integer-to-float casts (centralized in an internal `numeric` module), `PartLane`/`TerminalStage` lane enums replacing bool fields, and stale-allow deletions. No public API change.

## [0.2.0] - 2026-08-02
Expand Down
4 changes: 2 additions & 2 deletions src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ pub struct NonStreamingResponse {
///
/// fn stream_messages(
/// &self,
/// request: StreamRequest,
/// request: &StreamRequest,
/// ) -> Pin<Box<dyn Stream<Item = Result<StreamEvent, ApiError>> + Send + 'static>> {
/// // Clone data from &self, then build and return a stream
/// let model = self.model.clone();
Expand All @@ -193,7 +193,7 @@ pub struct NonStreamingResponse {
///
/// fn create_message(
/// &self,
/// request: StreamRequest,
/// request: &StreamRequest,
/// ) -> Pin<Box<dyn Future<Output = Result<NonStreamingResponse, ApiError>> + Send + '_>> {
/// // Non-streaming fallback
/// todo!()
Expand Down
26 changes: 17 additions & 9 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,17 +156,25 @@ pub enum ParallelMode {
/// docs for the ordering invariant. Choose this for read-heavy,
/// multi-call turns where latency is the sum of independent operations.
///
/// # Side-effect divergence from Sequential
/// # Side-effects (same granularity in both modes)
///
/// In Sequential mode, loop detection and observer events fire on
/// **every** retry attempt — a tool that fails twice then succeeds
/// produces 3 detection operations and 3 observer pairs.
/// Detection, observer, hook, and health side-effects all fire on **every**
/// retry attempt in both modes. A tool that fails twice then succeeds
/// produces 3 detection operations, 3 observer PRE+POST pairs, and 3 health
/// recordings regardless of `ParallelMode`. All four side-effect targets are
/// thread-safe ([`DetectionManager`](crate::detection::DetectionManager)
/// and the observer registry use
/// `Mutex`/immutable-`Vec` interiors;
/// [`ToolHealthRegistry`](crate::tool::health::ToolHealthRegistry) uses
/// atomic counters), so concurrent retry attempts in Parallel mode dispatch
/// side-effects safely without serialization.
///
/// In Parallel mode, detection and observer side-effects fire **once**
/// on the final result only (they are not thread-safe). Health
/// tracking fires on **every** attempt in both modes (it uses atomic
/// counters). Intermediate retries during parallel dispatch are
/// invisible to loop detection and observers but visible to health.
/// The only retry-related difference between modes is **interleaving**, not
/// granularity: in Sequential the classic `[pre A, post A, pre B, post B]`
/// order is strict, while in Parallel the PRE/POST events for independent
/// calls in the same wave interleave as those calls progress concurrently.
/// Observers that pair `on_tool_pre`/`on_tool_post` should key on
/// `tool_call_id` (carried in both contexts), not on arrival order.
Parallel,
}

Expand Down
6 changes: 3 additions & 3 deletions src/engine/bare.rs
Original file line number Diff line number Diff line change
Expand Up @@ -246,8 +246,8 @@ pub struct BareLoop<C: ApiClient> {
/// [`compaction_result`](LoopMachine::compaction_result), and
/// [`inject`](LoopMachine::inject). It is (re)created at the top of every
/// [`run()`](crate::engine::core::Loop::run) call from the run config
/// and user prompt; before that it holds an empty placeholder so the struct
/// is always valid.
/// and user prompt; before that it holds an empty machine (no history, no
/// pending messages) so the struct is always valid.
machine: LoopMachine,

/// Framework managers bundle — holds all cross-cutting infrastructure.
Expand Down Expand Up @@ -524,7 +524,7 @@ impl<C: ApiClient> BareLoop<C> {
/// accumulated history, turns taken, or the machine's internal state). The
/// machine is (re)created at the top of every
/// [`run()`](crate::engine::core::Loop::run) call; before the first run
/// it holds an empty placeholder.
/// it holds an empty machine (no history, no pending messages).
#[must_use]
pub fn machine(&self) -> &LoopMachine {
&self.machine
Expand Down
183 changes: 122 additions & 61 deletions src/engine/bare/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -905,6 +905,7 @@ mod tests {
use crate::engine::core::ToolCall;
use crate::engine::{Run, RunConfig};
use crate::message::ToolContent;
use crate::reflection::{FailureAnalysis, FailureSeverity};
use crate::tool::{
Tool, ToolContext, ToolError, ToolOutput, ToolSchema, registry::ToolRegistry,
};
Expand All @@ -918,6 +919,37 @@ mod tests {

use super::*;

/// Reflector that marks every failure recoverable, used by the recovery tests.
struct AlwaysRecoverable;
impl crate::reflection::Reflector for AlwaysRecoverable {
fn analyze(
&self,
error: &str,
tool_name: &str,
_tool_input: &Value,
_tool_schema: Option<&crate::tool::ToolSchema>,
_context: &crate::reflection::ReflectionContext,
) -> Pin<
Box<
dyn Future<Output = Result<FailureAnalysis, crate::reflection::ReflectionError>>
+ Send
+ '_,
>,
> {
let error = error.to_string();
let tool_name = tool_name.to_string();
Box::pin(async move {
Ok(FailureAnalysis {
is_recoverable: true,
root_cause: error,
severity: FailureSeverity::Medium,
correction: None,
context: format!("tool: {tool_name}"),
})
})
}
}

#[test]
fn truncate_to_short_string_unchanged() {
assert_eq!(truncate_to("hello", 10), "hello");
Expand Down Expand Up @@ -1398,39 +1430,7 @@ mod tests {

#[tokio::test]
async fn recovery_backoff_cancelled_promptly() {
use crate::reflection::{
FailureAnalysis, FailureSeverity, RecoveryAction, RecoveryStrategy,
};

struct AlwaysRecoverable;
impl crate::reflection::Reflector for AlwaysRecoverable {
fn analyze(
&self,
error: &str,
tool_name: &str,
_tool_input: &Value,
_tool_schema: Option<&crate::tool::ToolSchema>,
_context: &crate::reflection::ReflectionContext,
) -> Pin<
Box<
dyn Future<Output = Result<FailureAnalysis, crate::reflection::ReflectionError>>
+ Send
+ '_,
>,
> {
let error = error.to_string();
let tool_name = tool_name.to_string();
Box::pin(async move {
Ok(FailureAnalysis {
is_recoverable: true,
root_cause: error,
severity: FailureSeverity::Medium,
correction: None,
context: format!("tool: {tool_name}"),
})
})
}
}
use crate::reflection::{FailureAnalysis, RecoveryAction, RecoveryStrategy};

struct SlowRetry;
impl RecoveryStrategy for SlowRetry {
Expand Down Expand Up @@ -1484,42 +1484,103 @@ mod tests {
}

#[tokio::test]
async fn execute_tool_call_runs_recovery_on_failure() {
use crate::reflection::{
FailureAnalysis, FailureSeverity, RecoveryAction, RecoveryStrategy,
};
async fn parallel_retried_call_fires_side_effects_per_attempt() {
// Pins the documented contract (config.rs `ParallelMode`): detection,
// observer, hook, and health side-effects fire on EVERY retry attempt
// in BOTH modes. A retried parallel call must therefore emit multiple
// observer PRE+POST pairs, not one. Guards against a future change
// re-introducing per-mode gating that the contract explicitly disclaims
// (all side-effect targets are Send + Sync).
use crate::observer::{LoopObserver, ToolPostContext, ToolPreContext};
use crate::reflection::{FailureAnalysis, RecoveryAction, RecoveryStrategy};
use std::sync::atomic::{AtomicU32, Ordering};

struct AlwaysRecoverable;
impl crate::reflection::Reflector for AlwaysRecoverable {
fn analyze(
// Retry the first two attempts, then give up with a soft error so the
// call terminates. Each attempt is a full dispatch with PRE+POST.
struct RetryTwice;
impl RecoveryStrategy for RetryTwice {
fn decide(
&self,
error: &str,
tool_name: &str,
_tool_input: &Value,
_tool_schema: Option<&crate::tool::ToolSchema>,
_context: &crate::reflection::ReflectionContext,
) -> Pin<
Box<
dyn Future<Output = Result<FailureAnalysis, crate::reflection::ReflectionError>>
+ Send
+ '_,
>,
> {
let error = error.to_string();
let tool_name = tool_name.to_string();
_analysis: &FailureAnalysis,
attempt: u32,
_max_attempts: u32,
) -> Pin<Box<dyn Future<Output = RecoveryAction> + Send + '_>> {
Box::pin(async move {
Ok(FailureAnalysis {
is_recoverable: true,
root_cause: error,
severity: FailureSeverity::Medium,
correction: None,
context: format!("tool: {tool_name}"),
})
if attempt < 2 {
RecoveryAction::Retry {
delay: std::time::Duration::ZERO,
}
} else {
RecoveryAction::Skip("giving up".into())
}
})
}
}

struct CountingObserver {
pre: Arc<AtomicU32>,
post: Arc<AtomicU32>,
}
impl LoopObserver for CountingObserver {
fn name(&self) -> &'static str {
"counting"
}
fn on_tool_pre(&self, _ctx: &ToolPreContext) {
self.pre.fetch_add(1, Ordering::Relaxed);
}
fn on_tool_post(&self, _ctx: &ToolPostContext) {
self.post.fetch_add(1, Ordering::Relaxed);
}
}

// Tool that always errors; recovery drives 3 attempts (2 retries + 1 skip).
let error_tool = crate::tool::FnTool::new(
"error_tool".into(),
"Always errors".into(),
Value::Object(serde_json::Map::new()),
|_, _| Box::pin(async { Err(ToolError::Execution("boom".to_string())) }),
);

let pre_count = Arc::new(AtomicU32::new(0));
let post_count = Arc::new(AtomicU32::new(0));

let mut registry = ToolRegistry::new();
registry.register(error_tool);
let mut bare = make_parallel_loop(registry);
bare.set_reflector(Arc::new(AlwaysRecoverable));
bare.set_recovery_strategy(Arc::new(RetryTwice));
bare.register_observer(Arc::new(CountingObserver {
pre: Arc::clone(&pre_count),
post: Arc::clone(&post_count),
}));

let calls = vec![make_call("1", "error_tool", Value::Null)];
let _ = bare
.dispatch_tools(&calls, 0)
.await
.expect("dispatch should not hard-error");

let pres = pre_count.load(Ordering::Relaxed);
let posts = post_count.load(Ordering::Relaxed);
assert_eq!(
pres, 3,
"RetryTwice does 2 retries + 1 final skip = 3 attempts = 3 PRE events; got {pres}"
);
assert_eq!(
posts, 3,
"matching 3 POST events for the 3 attempts; got {posts}"
);
assert_eq!(
pres, posts,
"every PRE must have a matching POST (pairing invariant)"
);
}

#[tokio::test]
async fn execute_tool_call_runs_recovery_on_failure() {
use crate::reflection::{FailureAnalysis, RecoveryAction, RecoveryStrategy};
use std::sync::atomic::{AtomicU32, Ordering};

struct CountingRetry {
calls: Arc<AtomicU32>,
}
Expand Down