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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/2.0.0.

## [Unreleased]

## [0.2.1] - 2026-08-04

### Fixed

- Detection-layer long-tail correctness (six items): `check_file_reads` now normalizes the query under the recorded op's real tool name (was: empty string) and matches by bidirectional path containment (was: exact equality), and rejects empty normalized params (an empty string is a substring of every query, which would inflate the read count); `LoopDetector::new` clamps `window_size == 0` to 1 with a warning; `find_best_match` rejects zero-score candidates even at threshold 0.0 and breaks ties lexicographically; `ToolShield::with_thresholds` swaps inverted warn/block pairs with a warning and rejects non-finite (NaN/inf) values, falling back to the band's default — a stored NaN would silently disable the band because every `score >= NaN` comparison is false. All non-breaking.
- `SessionConfig::compact_threshold` now clamps into its documented `0..=100` range on the validating construction paths (`Default`, `with_compact_threshold`, and `Deserialize`). Previously only the `with_compact_threshold` builder clamped; deserialized configs could carry an out-of-range value (e.g. `200` from disk), which the compaction subsystem would then interpret as "never compact." A single canonical clamp method plus a field-level serde deserialize helper now enforce the range silently. `Default` was already in range (80); its clamp call is defensive. Direct public struct-literal construction (`SessionConfig { compact_threshold: 200, .. }`) still bypasses normalization — the field is `pub`, and callers using that form are responsible for honoring the documented range. Non-breaking (silent clamp; no signature changes).
- `TokenBucket` refill clock no longer jumps to a future instant. The shared `elapsed_refill` helper advances `last_refill` only to the fill point (the instant capacity is reached) instead of to `at`, so a caller that passes a far-future instant — directly or via `take`/`acquire`/`available` — can no longer freeze the refill clock on the production rate-limit path. Non-breaking (callers passing correct `Instant::now()` values see no change).
- 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`.
Expand Down Expand Up @@ -118,5 +122,6 @@ Initial crates.io release.
- Built-in testing utilities for writing LLM loop tests
- Example CLIs: hello, REPL, echo tool, and multi-provider chat

[0.2.1]: https://github.com/dch-labs/loopctl/releases/tag/v0.2.1
[0.2.0]: https://github.com/dch-labs/loopctl/releases/tag/v0.2.0
[0.1.0]: https://github.com/dch-labs/loopctl/releases/tag/v0.1.0
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "loopctl"
version = "0.2.0"
version = "0.2.1"
edition = "2024"
license = "MIT OR Apache-2.0"
description = "A trait-based framework for building agent loops with pluggable LLM clients, tools, and memory"
Expand Down
118 changes: 113 additions & 5 deletions src/detection/loop_detector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1098,7 +1098,13 @@ impl LoopDetector {
/// let config = LoopDetectorConfig { window_size: 100, ..Default::default() };
/// let detector = LoopDetector::new(config, Arc::new(NoOpToolSignature));
/// ```
pub fn new(config: LoopDetectorConfig, signature: Arc<dyn ToolSignature>) -> Self {
pub fn new(mut config: LoopDetectorConfig, signature: Arc<dyn ToolSignature>) -> Self {
if config.window_size == 0 {
tracing::warn!(
"LoopDetectorConfig.window_size was 0; clamping to 1 (the smallest sensible window)"
);
config.window_size = 1;
}
Self {
operations: Mutex::new(VecDeque::with_capacity(config.window_size)),
config,
Expand Down Expand Up @@ -1580,13 +1586,21 @@ impl LoopDetector {
};

let sig = &self.signature;
let normalized_input = sig.normalize_param_for_comparison("", file_path);
let read_count = ops
.iter()
.filter(|o| {
sig.is_file_read_tool(&o.tool)
&& sig.normalize_param_for_comparison(&o.tool, &o.primary_param)
== normalized_input
if !sig.is_file_read_tool(&o.tool) {
return false;
}
let normalized_op = sig.normalize_param_for_comparison(&o.tool, &o.primary_param);
let normalized_query = sig.normalize_param_for_comparison(&o.tool, file_path);

if normalized_op.is_empty() || normalized_query.is_empty() {
return false;
}

normalized_op.contains(normalized_query.as_str())
|| normalized_query.contains(normalized_op.as_str())
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})
.count();

Expand Down Expand Up @@ -2539,4 +2553,98 @@ mod tests {
"Should contain tool signature suggestion: {msg}"
);
}

/// Signature that flags the `Read` tool as a file-read tool and prefixes
/// the primary parameter with the tool name during comparison, so tests
/// can verify which tool name `check_file_reads` passes to
/// `normalize_param_for_comparison`.
struct ToolNameNormalizingSig;

impl ToolSignature for ToolNameNormalizingSig {
fn is_file_read_tool(&self, tool: &str) -> bool {
tool == "read"
}

fn normalize_param_for_comparison(&self, tool: &str, param: &str) -> String {
format!("{tool}:{param}")
}
}

#[test]
fn check_file_reads_matches_containment_both_directions() {
// Use the shared TestToolSignature which treats "Read" as a file-read
// tool and leaves params unchanged under normalization. Lower the
// threshold so a single recorded read trips the check.
let config = LoopDetectorConfig {
max_same_file_reads: 1,
..Default::default()
};
let mk = || LoopDetector::new(config.clone(), Arc::new(TestToolSignature));

// Forward direction: op path "/a/b/c" contains query "/a/b".
let detector = mk();
detector.record(Operation::new("Read", "/a/b/c"));
assert!(
detector.check_file_reads("/a/b"),
"op path containing the query path should match"
);

// Reverse direction: query "/a/b/c" contains op path "/a".
let detector2 = mk();
detector2.record(Operation::new("Read", "/a"));
assert!(
detector2.check_file_reads("/a/b/c"),
"query path containing the op path should match"
);
}

#[test]
fn check_file_reads_uses_real_tool_name() {
// ToolNameNormalizingSig prepends the tool name during normalization,
// so the match only succeeds if check_file_reads passes the recorded
// op's tool name ("read") rather than an empty string.
let detector = LoopDetector::new(
LoopDetectorConfig {
max_same_file_reads: 1,
..Default::default()
},
Arc::new(ToolNameNormalizingSig),
);

detector.record(Operation::new("read", "/file"));
// normalize_param_for_comparison("read", "/file") == "read:/file"
// and the query is normalized with the same tool name, so the
// containment check only holds if both use "read".
assert!(
detector.check_file_reads("/file"),
"check_file_reads must normalize under the recorded op's tool name"
);
}

#[test]
fn check_file_reads_ignores_empty_primary_param() {
let config = LoopDetectorConfig {
max_same_file_reads: 1,
..Default::default()
};
let detector = LoopDetector::new(config, Arc::new(TestToolSignature));
detector.record(Operation::new("Read", ""));
assert!(
!detector.check_file_reads("/etc/passwd"),
"an empty primary param must not match every query"
);
}

#[test]
fn window_size_zero_clamps_to_one() {
let config = LoopDetectorConfig {
window_size: 0,
..Default::default()
};
let detector = LoopDetector::new(config, Arc::new(TestToolSignature));
assert_eq!(
detector.config.window_size, 1,
"a window_size of 0 must be clamped to 1"
);
}
}
55 changes: 49 additions & 6 deletions src/middleware/unknown_tool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,9 +148,11 @@ impl UnknownToolMiddleware {

/// Find the best matching tool name from a list, given a threshold.
///
/// Returns the name with the highest similarity score that meets or
/// exceeds `threshold`. Returns `None` when no candidate scores high
/// enough.
/// Returns the name with the highest similarity score that exceeds zero
/// and meets or exceeds `threshold`. Ties (equal scores) are broken
/// lexicographically — the smaller name wins — so the result is
/// deterministic regardless of candidate order. Returns `None` when no
/// candidate scores high enough.
fn find_best_match_inner<'a>(
requested: &str,
available: &[&'a str],
Expand All @@ -159,10 +161,18 @@ impl UnknownToolMiddleware {
let mut best: Option<(&'a str, f64)> = None;
for &name in available {
let score = Self::similarity(requested, name);
if score >= threshold {
if score > 0.0 && score >= threshold {
match best {
Some((_, best_score)) if score <= best_score => {}
_ => best = Some((name, score)),
None => best = Some((name, score)),
Some((_, best_score)) if score > best_score => {
best = Some((name, score));
}
Some((best_name, best_score))
if (score - best_score).abs() < f64::EPSILON && name < best_name =>
{
best = Some((name, score));
}
_ => {}
}
}
}
Expand Down Expand Up @@ -385,6 +395,39 @@ mod tests {
);
}

#[test]
fn find_best_match_threshold_zero_rejects_zero_score() {
let available = ["bbb"];
assert!(
UnknownToolMiddleware::find_best_match_inner("xyz", &available, 0.0).is_none(),
"a zero-similarity candidate must be rejected even at threshold 0.0"
);
}

#[test]
fn find_best_match_ties_break_lexicographically() {
let forward = ["toll", "toil"];
let (suggestion, score) =
UnknownToolMiddleware::find_best_match_inner("tool", &forward, 0.0).unwrap();
assert_eq!(
suggestion, "toil",
"tie should break to the lexicographically smaller name"
);
assert!(
(score - 0.8).abs() < 1e-6,
"expected tied score 0.8, got {score}"
);

// Reversed input order must yield the same winner.
let reversed = ["toil", "toll"];
let (suggestion_rev, _) =
UnknownToolMiddleware::find_best_match_inner("tool", &reversed, 0.0).unwrap();
assert_eq!(
suggestion_rev, "toil",
"tie-break must be order-independent"
);
}

use crate::message::ToolContent;
use crate::tool::ToolDispatchResult;
use std::time::Duration;
Expand Down
Loading