Skip to content

refactor - #36

Merged
bobrykov merged 30 commits into
masterfrom
refactor/events
Jul 1, 2026
Merged

refactor#36
bobrykov merged 30 commits into
masterfrom
refactor/events

Conversation

@bobrykov

@bobrykov bobrykov commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

No description provided.

@coderabbitai

coderabbitai Bot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@bobrykov, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 30 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e6d12778-c80b-405c-9024-55567a4e8033

📥 Commits

Reviewing files that changed from the base of the PR and between 5fd7df9 and 0a0364b.

📒 Files selected for processing (3)
  • README.md
  • src/api.rs
  • src/memory/builtin.rs
📝 Walkthrough

Walkthrough

The crate is reorganized around loop-centric modules and types, with new runtime, observer, memory, detection, reflection, compaction, and middleware subsystems. BareLoop, error handling, configuration, examples, and build/docs wiring are updated to the new public API surface.

Changes

Core surface and runtime types

Layer / File(s) Summary
Crate surface and docs
src/lib.rs, README.md, Cargo.toml, Makefile, .github/workflows/ci.yml, src/message.rs, src/cancel.rs, src/builder/error.rs, src/hooks.rs
Public modules, docs, feature flags, examples, and shared wording are updated for the new module taxonomy and build layout.
LoopError, LoopConfig, capabilities, and loop-core types
src/error.rs, src/config.rs, src/api.rs, src/api/error.rs, src/capabilities.rs, src/engine/loop_core.rs, src/engine.rs
Core error/config/API surfaces are renamed or expanded, capability traits are added, and loop lifecycle/result types are introduced and exported.
Observer, memory, reflection, and compaction subsystems
src/observer.rs, src/observer/context.rs, src/memory.rs, src/memory/entry.rs, src/memory/builtin.rs, src/reflection.rs, src/reflection/backoff.rs, src/compact.rs, src/compact/types.rs, src/compact/truncating.rs, src/hooks/context.rs
Typed observer dispatch, memory storage types/backends, reflection correction/backoff, and compaction context/outcome logic are added or refactored together.

Runtime, detection, and dispatch

Layer / File(s) Summary
Detection and fallback updates
src/detection.rs, src/detection/convergence.rs, src/detection/loop_detector.rs, src/detection/manager.rs, src/fallback.rs, src/cancel.rs
Detection module re-exports, loop/convergence behavior, fallback builder/state APIs, and token-based cancellation are updated around the new runtime contracts.
BareLoop runtime rewrite
src/engine/bare.rs, src/engine/bare/compact.rs, src/engine/bare/dispatch.rs, src/engine/bare/emission.rs, src/engine/bare/message.rs, src/engine/bare/stream.rs
BareLoop switches to LoopConfig, LoopRuntime, LoopError, typed observer contexts, correction threading, and the new loop-core dispatch flow across constructors, streaming, compaction, and session finalization.
Middleware split and tool dispatch middlewares
src/middleware.rs, src/middleware/output_limit.rs, src/middleware/permission.rs, src/middleware/timeout.rs, src/middleware/tool_call.rs, src/middleware/unknown_tool.rs, src/hooks/executor.rs, src/hooks/builtin/auto_commit.rs
Middleware is split into submodules and new dispatch middlewares are added for output limiting, permissions, timeouts, tool execution, and unknown-tool suggestions; builder-style APIs are made fluent.

Examples and validation

Layer / File(s) Summary
Examples, docs, and CI wiring
examples/chat.rs, examples/echo-tool-cli.rs, examples/hello-cli.rs, examples/repl-cli.rs
Adds example CLIs for provider chat, echo-tool dispatch, hello-loop, and REPL flows with tool registration and mock/provider client wiring.
Possibly related PRs
  • dch-labs/loopctl#3: shares the same error-surface migration from AgentError to LoopError.
  • dch-labs/loopctl#4: overlaps on the observer-trait surface that is replaced here.
  • dch-labs/loopctl#22: closely related to the compaction framework additions and ContextManager compaction behavior changes.
🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Title check ❓ Inconclusive The title is too vague and generic to convey the main change in this large refactor. Replace it with a concise, specific title summarizing the primary change, such as the new loop/runtime architecture or module API refactor.
✅ Passed checks (3 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 50.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/events

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 18

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (9)
src/engine/bare.rs (1)

1083-1097: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid emitting a successful turn-end before compaction can fail.

dispatch_and_record emits a successful on_turn_end, then maybe_compact_context can fail and abort_turn_and_session emits a failed on_turn_end for the same logical turn. Delay the success notification until after post-turn compaction succeeds, or make the compaction failure path session-only once a turn-end has already been emitted.

Also applies to: 1269-1276

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/engine/bare.rs` around lines 1083 - 1097, The current code flow emits a
successful on_turn_end event from dispatch_and_record, then if
maybe_compact_context fails afterward, abort_turn_and_session emits a failed
on_turn_end for the same logical turn, resulting in conflicting turn-end
notifications. Fix this by either: (1) moving the success notification in
dispatch_and_record to occur only after maybe_compact_context completes
successfully, or (2) modifying the error handling path in abort_turn_and_session
to emit session-level errors only (not turn-level) when a turn-end has already
been successfully emitted. Apply the same fix pattern at both occurrences of
this code (around line 1083-1097 and line 1269-1276).
src/compact.rs (2)

668-674: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Telemetry currently undercounts tool-involved messages.

Line 673 only checks is_tool_call, but PreCompactStats.tool_messages is documented as counting tool calls or results. This skews compaction telemetry.

Proposed fix
                 tool_messages: pre_messages
                     .iter()
                     .filter(|m| {
                         m.parts
                             .iter()
-                            .any(crate::message::MessagePart::is_tool_call)
+                            .any(|p| p.is_tool_call() || p.is_tool_result())
                     })
                     .count(),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/compact.rs` around lines 668 - 674, The filter for tool_messages in the
compact operation at line 673 only checks for is_tool_call but according to the
PreCompactStats.tool_messages documentation, it should count both tool calls and
tool results. Update the filter condition in the any() call to check for both
is_tool_call and is_tool_result (or whatever the method name is for checking
tool results) to ensure tool-involved messages are correctly counted in the
compaction telemetry.

585-622: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

compact_manual misses the post-compaction overflow check promised by its contract.

The docs on Line 585 state this method errors when compaction still exceeds the context window, but the implementation returns Compacted(outcome) without validating tokens_after. That can propagate an oversized conversation as a success.

Proposed fix
         if !outcome.success {
             return Err(ContextOverflow {
                 tokens_used: tokens_before,
                 context_window: self.context_window,
                 message_count,
                 trigger: CompactReason::Manual,
                 compactor_error: outcome.error,
             });
         }
 
+        let tokens_after = Self::estimate_tokens(&outcome.messages);
+        if tokens_after > self.context_window {
+            return Err(ContextOverflow {
+                tokens_used: tokens_after,
+                context_window: self.context_window,
+                message_count: outcome.messages.len(),
+                trigger: CompactReason::Manual,
+                compactor_error: Some("compactor failed to reduce context below window".into()),
+            });
+        }
+
         Ok(EnsureContextResult::Compacted(outcome))
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/compact.rs` around lines 585 - 622, The compact_manual method
documentation states it should return a ContextOverflow error when the result
still exceeds the context window, but the current implementation only checks if
the compaction succeeded via outcome.success without validating whether the
compacted result fits within the context window. Add a second validation check
after the existing !outcome.success check to verify that outcome.tokens_after
does not exceed self.context_window, and return an error with ContextOverflow
containing the actual tokens_after value if this post-compaction overflow
condition is detected.
src/detection/convergence.rs (1)

537-575: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fix streak counting semantics in add_response.

On Line 549 and Line 566, streak is updated once per prior window item, then compared directly to window_size. This causes incorrect behavior (e.g., window_size = 2 with two identical responses does not converge). Update streak once per new response (based on prior-response relationship), then evaluate detection.

💡 Suggested direction
-        for prev_response in &self.window {
+        let mut similar_to_previous = false;
+        for (idx, prev_response) in self.window.iter().enumerate() {
             let similarity = Self::compute_similarity(response, prev_response);
             if similarity > max_similarity {
                 max_similarity = similarity;
             }
-
-            if similarity >= self.config.similarity_threshold {
-                self.consecutive_count = self.consecutive_count.saturating_add(1);
-                if !self.similar_responses.contains(&response.to_string()) {
-                    self.similar_responses.push(response.to_string());
-                }
-            } else {
-                self.consecutive_count = 1;
-                self.similar_responses.clear();
-                self.similar_responses.push(response.to_string());
-            }
+            if idx + 1 == self.window.len() {
+                similar_to_previous = similarity >= self.config.similarity_threshold;
+            }
         }
+
+        if self.window.is_empty() {
+            self.consecutive_count = 1;
+        } else if similar_to_previous {
+            self.consecutive_count = self.consecutive_count.saturating_add(1);
+        } else {
+            self.consecutive_count = 1;
+            self.similar_responses.clear();
+        }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/detection/convergence.rs` around lines 537 - 575, The consecutive_count
streak is being updated inside the loop that iterates through all previous
responses in self.window, causing it to be incremented or reset multiple times
for a single new response. This breaks the streak semantics since the count
should increase by 1 per new response added (if it matches the previous response
pattern), not once per prior window item. Refactor the add_response method to
determine similarity once per new response (by checking against the appropriate
previous response, likely the most recent one in the window) and then update
self.consecutive_count exactly once based on that single similarity check,
before evaluating the convergence detection condition against
self.config.window_size.
src/fallback.rs (3)

1430-1470: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Trip the circuit inside record_api_failure().

BareLoop calls record_api_failure() on stream failures and does not call transition_to_fallback() afterward, so this path returns true but leaves the manager in Primary with fallback_activated == false. That makes fallback activation repeat on later failures instead of opening the circuit.

Proposed fix
         if failures >= self.fallback_threshold && !self.fallback_activated.load(Ordering::Relaxed) {
             warn!(
                 consecutive_failures = failures,
                 threshold = self.fallback_threshold,
                 "Fallback threshold reached"
             );
+            self.transition_to_fallback();
             true
         } else {
             false
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/fallback.rs` around lines 1430 - 1470, In the record_api_failure method,
when the failure count reaches the fallback threshold, you need to atomically
set the fallback_activated flag to true in addition to returning true. Currently
the method returns true to signal that fallback should be triggered, but it does
not actually set fallback_activated to true, which causes the circuit to never
be opened and allows the method to return true again on subsequent failures
instead of returning false. Add a store operation to fallback_activated with
Ordering::Relaxed before returning true in the threshold-reached condition.

305-329: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Preserve new_failed() when changing the threshold.

FallbackEntry::new_failed("x").with_max_fail_count(3) currently becomes non-failed because new_failed() seeds two attempts, then the threshold is raised to three. Preserve the prior failed state when adjusting the threshold.

Proposed fix
 pub fn with_max_fail_count(mut self, max_fail_count: usize) -> Self {
+    let was_failed = self.failed();
     self.max_fail_count = max_fail_count.max(1);
+    if was_failed {
+        while self.attempts.len() < self.max_fail_count {
+            self.attempts.push(AttemptRecord::anonymous());
+        }
+    }
     self
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/fallback.rs` around lines 305 - 329, The `new_failed()` method
initializes a FallbackEntry with 2 attempts and max_fail_count of 2, marking it
as failed. However, when `with_max_fail_count()` is called with a higher value
(e.g., 3), the entry becomes non-failed because now it needs 3 attempts to be
considered failed. Modify the `with_max_fail_count()` method to preserve the
failed state by ensuring that if the entry was previously failed (attempts >=
current max_fail_count), it remains failed after the threshold adjustment by
adding additional attempts as needed to maintain that failed status with the new
max_fail_count threshold.

686-710: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Apply all config fields or stop advertising config-driven recovery.

FallbackConfig::recovery_timeout is part of the config API, but with_config() discards it, so callers using this method do not actually configure the recovery cooldown. Either store/apply the timeout in the manager API or make the docs explicit that callers must pass config.recovery_timeout separately to should_try_resume_primary().

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/fallback.rs` around lines 686 - 710, The with_config() method is missing
the assignment for the recovery_timeout field from FallbackConfig. Add a line to
assign config.recovery_timeout to the corresponding field in the FallbackManager
instance (likely self.recovery_timeout or similar based on the pattern of the
other three assignments), ensuring that all fields advertised in the
FallbackConfig are actually applied when callers use this convenience method.
src/detection/manager.rs (2)

269-307: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Wire max_response_history into convergence config or remove it.

Line 269 documents max_response_history as controlling retained responses, but to_convergence_config() never passes it to the ConvergenceDetector, so changing this public field has no effect through DetectionManager.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/detection/manager.rs` around lines 269 - 307, The field
max_response_history is defined in DetectionConfig and initialized in the
Default implementation, but it is never passed to ConvergenceConfig in the
to_convergence_config() method. Either wire this field into the convergence
config by adding it to the ConvergenceConfig struct initialization (if
ConvergenceConfig has a corresponding field for max_response_history), or remove
the max_response_history field entirely from DetectionConfig along with its
documentation comment if it is not used by the downstream ConvergenceDetector.

527-552: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep injected detector thresholds consistent with config().

This constructor says the loop-related config fields are ignored, but the manager still exposes that same config; downstream BareLoop uses detection.config().stop_threshold to decide when to stop. An injected LoopDetector with a different stop threshold can warn according to one configuration and stop according to another. Either synchronize the stored config with the injected detector or change the runtime stop check to use LoopStatus::should_stop.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/detection/manager.rs` around lines 527 - 552, The new_with_loop_detector
constructor accepts a pre-built LoopDetector but still stores and exposes the
original config via the config() method. This creates an inconsistency because
downstream code (in BareLoop) uses detection.config().stop_threshold to make
stopping decisions, while the injected LoopDetector may have different
thresholds baked in. Either extract the actual threshold values from the
injected loop_detector and update the stored config to match those values
(ensuring consistency), or update the runtime stop check in BareLoop to use
LoopStatus::should_stop instead of directly accessing config().stop_threshold,
so it respects the detector's actual configuration.
🧹 Nitpick comments (2)
src/reflection/backoff.rs (1)

129-139: ⚡ Quick win

Use max_attempts in decide to avoid policy drift with the caller.

Line 133 receives max_attempts but ignores it. Since the caller also enforces a cap in src/engine/bare/dispatch.rs (Lines 306-331), the strategy can emit Retry decisions that are immediately discarded. Consider combining both limits in one place.

Suggested change
-    fn decide(
+    fn decide(
         &self,
         analysis: &FailureAnalysis,
         attempt: u32,
-        _max_attempts: u32,
+        max_attempts: u32,
     ) -> Pin<Box<dyn Future<Output = RecoveryAction> + Send + '_>> {
+        let retry_cap = self.max_retries.min(max_attempts);
         let action = if !analysis.is_recoverable {
             RecoveryAction::Fail(analysis.root_cause.clone())
-        } else if attempt >= self.max_retries {
-            RecoveryAction::Fail(format!("max retries ({}) exceeded", self.max_retries))
+        } else if attempt >= retry_cap {
+            RecoveryAction::Fail(format!("max retries ({retry_cap}) exceeded"))
         } else if analysis.severity >= FailureSeverity::High && analysis.correction.is_some() {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/reflection/backoff.rs` around lines 129 - 139, The `decide` method
receives the `max_attempts` parameter but ignores it when making recovery
decisions, while the caller in `src/engine/bare/dispatch.rs` also enforces a
separate cap, causing potential policy drift. In the decision logic where you
check `attempt >= self.max_retries`, extend this condition to also account for
the `max_attempts` parameter so that both limits are considered in one place,
preventing the strategy from emitting Retry decisions that would be immediately
discarded by the caller.
src/hooks/executor.rs (1)

81-101: 💤 Low value

Redundant builder methods with identical semantics.

Both with_interactivity and interactivity now take mut self, set the same field, and return Self. They are functionally identical.

Additionally, the doc comment on with_interactivity (line 83) suggests using interactivity "to change the mode after construction", but interactivity also consumes self, so this guidance is misleading.

Consider keeping only one method (e.g., with_interactivity to match with_hook), or clarifying the intended distinction if there is one.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/executor.rs` around lines 81 - 101, Remove the redundant builder
method to eliminate duplication in HookExecutor. Both with_interactivity and
interactivity methods are functionally identical - they both take mut self, set
the same interactivity field, and return Self for chaining. Keep the
with_interactivity method since it aligns with the naming pattern of with_hook,
and delete the interactivity method entirely. Update the doc comment for
with_interactivity to remove the misleading reference to calling interactivity
"after construction" since both methods consume self via mut self and are used
in the builder pattern, not for post-construction mutation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/compact.rs`:
- Around line 201-220: The `#[default]` attribute in the `CompactBase` enum is
currently applied to the `Context` variant, but the documentation and
`ContextManager::new` indicate that `Threshold` should be the default behavior.
Move the `#[default]` attribute from the `Context` variant to the `Threshold`
variant to ensure that `CompactBase::default()` returns the same default variant
that is documented and used throughout the codebase.

In `@src/detection/loop_detector.rs`:
- Around line 1369-1434: The find_repeated function collects operations from a
HashMap which has non-deterministic iteration order, causing the repeated vector
to have varying order across calls. When multiple operations have the same max
count (tie cases), build_warning's first() call will select different operations
non-deterministically, breaking the warning suppression logic. Sort the repeated
operations vector in a stable, deterministic order (such as by tool name or
operation name) before returning from find_repeated to ensure build_warning
consistently selects the same operation when ties occur.
- Around line 1506-1512: The check_file_reads method has asymmetrical
normalization where o.primary_param is normalized using
sig.normalize_param_for_comparison but is compared against raw file_path.
Normalize file_path using the same sig.normalize_param_for_comparison method
before the comparison to ensure consistency. Store the normalized file_path in a
variable before the ops.iter() call and use that normalized value in the filter
comparison, matching the pattern used in clear_warnings_for_recoverable_edit.

In `@src/detection/manager.rs`:
- Around line 411-414: The documentation comment describing the manager
lifecycle still references the old constructor name with_config() which has been
renamed to new_with_config(). Update the lifecycle documentation in the manager
to replace the reference from with_config() to new_with_config() to accurately
reflect the current public API.

In `@src/engine/bare.rs`:
- Around line 1269-1276: The on_turn_end notifications are using
budget.turn_count which gets incremented before the notification is sent,
causing a mismatch with on_turn_start which uses the turn index before
incrementing. Replace budget.turn_count with turn.idx in all TurnEndContext
calls to on_turn_end (both at the shown location and at lines 1281-1288) to
ensure consistent turn indices between on_turn_start and on_turn_end
notifications and maintain the pairing contract.
- Line 55: The documentation examples in the bare.rs file contain references to
the removed `core` module, specifically the commented-out import statement
showing `loopctl::core::LoopConfig`. Update all three instances of stale module
paths (at the locations indicated: 55, 158, and 917) by replacing references to
`loopctl::core` with the correct paths from the new public API surface. Ensure
all documentation examples and imports are updated to use the current module
structure to prevent users from encountering errors when following the docs and
to ensure doctests pass.

In `@src/engine/bare/dispatch.rs`:
- Around line 105-116: The `on_tool_pre` callback fires before the
`check_pre_tool_hooks` and `pre_detection` blocking checks, but early returns
from these checks skip the corresponding `on_tool_post` callback, creating
unpaired observer lifecycle events. Fix this by either moving the `on_tool_pre`
call after both blocking checks so it only fires for non-blocked paths, or add
`on_tool_post` with an error ToolPostContext when `check_pre_tool_hooks` or
`pre_detection` return a blocked result before the early return.
- Around line 466-472: After the tokio::select! block where pipeline.invoke(ctx)
completes and dispatch_result is obtained, add a check to see if the
cancellation signal has been set by calling cancel.is_cancelled(). If the
cancellation signal is set after pipeline invocation completes, return
Err(LoopError::Cancelled) immediately instead of continuing to construct and
return the ToolDispatchResult. This ensures that late-arriving cancellations are
properly propagated rather than being masked as soft tool errors by
ToolCallMiddleware, matching the behavior of ToolPipeline::dispatch_all.
- Around line 177-186: The `map` operation on the Result returned by
`handle_detected_pattern` is converting the error case into a soft tool result,
which discards the hard loop-detection error and bypasses the configured stop
policy. Instead of mapping the success case to a ToolDispatchResult, you should
use `match` or similar error propagation to handle both the Ok and Err cases of
`handle_detected_pattern`. When `Err(LoopError::LoopDetected)` is returned,
propagate that error up to the caller rather than converting it to a soft
ToolDispatchResult, allowing the configured stop policy to be properly enforced.
Only create a ToolDispatchResult for the success case or for actual tool
execution paths.

In `@src/memory.rs`:
- Around line 1-12: Update the module-level documentation comment in memory.rs
to reflect the current public API. Replace the outdated "Agent memory" wording
in the initial description and update the "Provided Implementations" section to
list LoopMemory and InMemoryStore instead of TrajectoryMemory, ensuring the
documentation accurately describes the actual public exports of the module.

In `@src/memory/builtin.rs`:
- Around line 141-143: The rustdoc comment for the with_entries method contains
incomplete and disconnected sentences that make the documentation unclear. The
first line "Useful for setting up test fixtures or seeding an agent with" is a
sentence fragment that doesn't complete its thought, and it's awkwardly
separated from the following line. Revise the rustdoc comment above the
with_entries method to form complete, coherent sentences that clearly explain
what the method does and its use cases, ensuring all sentences are grammatically
complete and logically connected.

In `@src/middleware/permission.rs`:
- Around line 39-45: The Rustdoc example for PermissionMiddleware contains two
API inconsistencies that prevent the code from compiling. First, verify the
correct method or constructor for creating a PermissionMiddleware with a
permission check (the example currently shows
`PermissionMiddleware::with_check(...)` which may not match the actual API
shape). Second, update the `PermissionCheck::Deny` variant to include its
required `reason` field with an appropriate string value explaining why the
permission was denied. Update the example code block within the documentation
comment to reflect the correct, working API so readers can use it as a valid
reference.

In `@src/middleware/timeout.rs`:
- Around line 68-69: The documentation comment for the `from_secs` method
incorrectly describes the default retry settings as including one retry with
double timeout. Update this comment to accurately reflect the actual default
values: `retry_on_timeout: false` and `max_retries: 0`, which means retries are
disabled by default. The corrected documentation should clearly state that no
retries occur with default settings, allowing users to understand the actual
reliability behavior without confusion.

In `@src/middleware/unknown_tool.rs`:
- Around line 25-27: Update the Rustdoc example for the UnknownToolMiddleware
struct to reflect the current signature of the new method. The example currently
shows UnknownToolMiddleware::new() being called without arguments, but the
method now requires an Arc<ToolRegistry> parameter. Modify the example to
properly instantiate a ToolRegistry, wrap it in an Arc, and pass it to the new
method call.
- Around line 163-178: The is_tool_not_found function is too broad in its check
by simply looking for "not found" anywhere in the error message, which will
incorrectly match unrelated errors like file or resource not found errors. Make
the pattern matching more specific to tool-related errors by checking for error
patterns that specifically indicate a tool was not found (such as checking for
"tool" in the error message alongside "not found", or looking for other
tool-specific keywords) instead of just matching any error containing "not
found".
- Around line 191-203: The tool_name is captured before the next.dispatch(ctx)
call, but middleware can mutate ctx.tool_name during dispatch to redirect to a
different tool. When generating suggestions in the is_tool_not_found block, you
are using the stale tool_name instead of the potentially updated one. Move the
tool_name capture to after the dispatch completes and use the resolved tool name
from the result object (or updated context) when calling
Self::find_best_match_inner to ensure suggestions are generated for the correct
redirected tool name.

In `@src/observer.rs`:
- Around line 142-146: The runtime call order in src/engine/bare.rs violates the
documented lifecycle contract for the reset method in the Observer trait. The
current implementation calls on_session_start before reset, which causes
per-session observer state to be wiped immediately after initialization. Reorder
the method calls in the runtime so that reset is invoked before on_session_start
to match the trait's documented contract and preserve observer state during
session start notification.

In `@src/observer/context.rs`:
- Around line 97-106: The TurnEndContext fields input_tokens and output_tokens
are documented as per-turn token counts, but in src/engine/bare.rs when
constructing TurnEndContext during the pattern short-circuit path, cumulative
session totals from budget.input_tokens and budget.output_tokens are being
passed instead. To fix this, locate where TurnEndContext is emitted in
src/engine/bare.rs (particularly in the early exit path) and replace the direct
assignment of budget.input_tokens and budget.output_tokens with calculations
that produce only the tokens consumed in the current turn. You may need to track
the token state at the turn's start and calculate the delta, or structure the
code to only accumulate tokens during the current turn's operations.

---

Outside diff comments:
In `@src/compact.rs`:
- Around line 668-674: The filter for tool_messages in the compact operation at
line 673 only checks for is_tool_call but according to the
PreCompactStats.tool_messages documentation, it should count both tool calls and
tool results. Update the filter condition in the any() call to check for both
is_tool_call and is_tool_result (or whatever the method name is for checking
tool results) to ensure tool-involved messages are correctly counted in the
compaction telemetry.
- Around line 585-622: The compact_manual method documentation states it should
return a ContextOverflow error when the result still exceeds the context window,
but the current implementation only checks if the compaction succeeded via
outcome.success without validating whether the compacted result fits within the
context window. Add a second validation check after the existing
!outcome.success check to verify that outcome.tokens_after does not exceed
self.context_window, and return an error with ContextOverflow containing the
actual tokens_after value if this post-compaction overflow condition is
detected.

In `@src/detection/convergence.rs`:
- Around line 537-575: The consecutive_count streak is being updated inside the
loop that iterates through all previous responses in self.window, causing it to
be incremented or reset multiple times for a single new response. This breaks
the streak semantics since the count should increase by 1 per new response added
(if it matches the previous response pattern), not once per prior window item.
Refactor the add_response method to determine similarity once per new response
(by checking against the appropriate previous response, likely the most recent
one in the window) and then update self.consecutive_count exactly once based on
that single similarity check, before evaluating the convergence detection
condition against self.config.window_size.

In `@src/detection/manager.rs`:
- Around line 269-307: The field max_response_history is defined in
DetectionConfig and initialized in the Default implementation, but it is never
passed to ConvergenceConfig in the to_convergence_config() method. Either wire
this field into the convergence config by adding it to the ConvergenceConfig
struct initialization (if ConvergenceConfig has a corresponding field for
max_response_history), or remove the max_response_history field entirely from
DetectionConfig along with its documentation comment if it is not used by the
downstream ConvergenceDetector.
- Around line 527-552: The new_with_loop_detector constructor accepts a
pre-built LoopDetector but still stores and exposes the original config via the
config() method. This creates an inconsistency because downstream code (in
BareLoop) uses detection.config().stop_threshold to make stopping decisions,
while the injected LoopDetector may have different thresholds baked in. Either
extract the actual threshold values from the injected loop_detector and update
the stored config to match those values (ensuring consistency), or update the
runtime stop check in BareLoop to use LoopStatus::should_stop instead of
directly accessing config().stop_threshold, so it respects the detector's actual
configuration.

In `@src/engine/bare.rs`:
- Around line 1083-1097: The current code flow emits a successful on_turn_end
event from dispatch_and_record, then if maybe_compact_context fails afterward,
abort_turn_and_session emits a failed on_turn_end for the same logical turn,
resulting in conflicting turn-end notifications. Fix this by either: (1) moving
the success notification in dispatch_and_record to occur only after
maybe_compact_context completes successfully, or (2) modifying the error
handling path in abort_turn_and_session to emit session-level errors only (not
turn-level) when a turn-end has already been successfully emitted. Apply the
same fix pattern at both occurrences of this code (around line 1083-1097 and
line 1269-1276).

In `@src/fallback.rs`:
- Around line 1430-1470: In the record_api_failure method, when the failure
count reaches the fallback threshold, you need to atomically set the
fallback_activated flag to true in addition to returning true. Currently the
method returns true to signal that fallback should be triggered, but it does not
actually set fallback_activated to true, which causes the circuit to never be
opened and allows the method to return true again on subsequent failures instead
of returning false. Add a store operation to fallback_activated with
Ordering::Relaxed before returning true in the threshold-reached condition.
- Around line 305-329: The `new_failed()` method initializes a FallbackEntry
with 2 attempts and max_fail_count of 2, marking it as failed. However, when
`with_max_fail_count()` is called with a higher value (e.g., 3), the entry
becomes non-failed because now it needs 3 attempts to be considered failed.
Modify the `with_max_fail_count()` method to preserve the failed state by
ensuring that if the entry was previously failed (attempts >= current
max_fail_count), it remains failed after the threshold adjustment by adding
additional attempts as needed to maintain that failed status with the new
max_fail_count threshold.
- Around line 686-710: The with_config() method is missing the assignment for
the recovery_timeout field from FallbackConfig. Add a line to assign
config.recovery_timeout to the corresponding field in the FallbackManager
instance (likely self.recovery_timeout or similar based on the pattern of the
other three assignments), ensuring that all fields advertised in the
FallbackConfig are actually applied when callers use this convenience method.

---

Nitpick comments:
In `@src/hooks/executor.rs`:
- Around line 81-101: Remove the redundant builder method to eliminate
duplication in HookExecutor. Both with_interactivity and interactivity methods
are functionally identical - they both take mut self, set the same interactivity
field, and return Self for chaining. Keep the with_interactivity method since it
aligns with the naming pattern of with_hook, and delete the interactivity method
entirely. Update the doc comment for with_interactivity to remove the misleading
reference to calling interactivity "after construction" since both methods
consume self via mut self and are used in the builder pattern, not for
post-construction mutation.

In `@src/reflection/backoff.rs`:
- Around line 129-139: The `decide` method receives the `max_attempts` parameter
but ignores it when making recovery decisions, while the caller in
`src/engine/bare/dispatch.rs` also enforces a separate cap, causing potential
policy drift. In the decision logic where you check `attempt >=
self.max_retries`, extend this condition to also account for the `max_attempts`
parameter so that both limits are considered in one place, preventing the
strategy from emitting Retry decisions that would be immediately discarded by
the caller.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b61b92e1-628b-4bc9-bf39-cc9cd7a1a0c1

📥 Commits

Reviewing files that changed from the base of the PR and between 7d63a14 and 2ac4687.

📒 Files selected for processing (65)
  • README.md
  • src/api.rs
  • src/api/error.rs
  • src/builder/error.rs
  • src/builtin.rs
  • src/builtin/observer.rs
  • src/cancel.rs
  • src/capabilities.rs
  • src/compact.rs
  • src/compact/truncating.rs
  • src/compact/types.rs
  • src/config.rs
  • src/core.rs
  • src/core/agent_core.rs
  • src/core/agent_memory.rs
  • src/core/agent_observer.rs
  • src/core/types.rs
  • src/detection.rs
  • src/detection/convergence.rs
  • src/detection/loop_detector.rs
  • src/detection/manager.rs
  • src/engine.rs
  • src/engine/bare.rs
  • src/engine/bare/compact.rs
  • src/engine/bare/dispatch.rs
  • src/engine/bare/emission.rs
  • src/engine/bare/stream.rs
  • src/engine/loop_core.rs
  • src/error.rs
  • src/fallback.rs
  • src/hooks.rs
  • src/hooks/builtin/auto_commit.rs
  • src/hooks/context.rs
  • src/hooks/executor.rs
  • src/lib.rs
  • src/loop_control.rs
  • src/loop_control/bundle.rs
  • src/memory.rs
  • src/memory/builtin.rs
  • src/memory/entry.rs
  • src/message.rs
  • src/middleware.rs
  • src/middleware/output_limit.rs
  • src/middleware/permission.rs
  • src/middleware/timeout.rs
  • src/middleware/tool_call.rs
  • src/middleware/unknown_tool.rs
  • src/observability.rs
  • src/observability/console.rs
  • src/observability/event.rs
  • src/observability/sink.rs
  • src/observer.rs
  • src/observer/context.rs
  • src/reflection.rs
  • src/reflection/backoff.rs
  • src/runtime.rs
  • src/stream.rs
  • src/stream/handler.rs
  • src/stream/heartbeat.rs
  • src/testing.rs
  • src/tool.rs
  • src/tool/health.rs
  • src/tool/permission.rs
  • src/tool/registry.rs
  • src/tool/shield.rs
💤 Files with no reviewable changes (14)
  • src/core/types.rs
  • src/observability.rs
  • README.md
  • src/builtin.rs
  • src/observability/event.rs
  • src/observability/sink.rs
  • src/core/agent_memory.rs
  • src/core.rs
  • src/observability/console.rs
  • src/builtin/observer.rs
  • src/core/agent_core.rs
  • src/loop_control/bundle.rs
  • src/core/agent_observer.rs
  • src/loop_control.rs

Comment thread src/compact.rs
Comment thread src/detection/loop_detector.rs
Comment thread src/detection/loop_detector.rs
Comment thread src/detection/manager.rs
Comment thread src/engine/bare.rs Outdated
Comment thread src/middleware/unknown_tool.rs
Comment thread src/middleware/unknown_tool.rs
Comment thread src/middleware/unknown_tool.rs Outdated
Comment thread src/observer.rs
Comment thread src/observer/context.rs
@bobrykov bobrykov changed the title refactor: observer events refactor Jun 29, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/compact/truncating.rs (1)

118-163: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Honor the requested token budget before reporting success.

compact() never uses target_tokens, and this implementation always returns success: true even when preserve_recent plus the preserved first message still exceeds the caller’s budget. With the new tool-pair backtracking, that preserved slice can grow further, so the loop can keep sending oversized contexts while compaction claims it succeeded.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/compact/truncating.rs` around lines 118 - 163, The compact()
implementation in Truncating compaction ignores the requested target_tokens and
can return success even when the preserved messages still exceed the budget.
Update compact() to compare the estimated tokens for the final preserved slice
against target_tokens, and only report success when the slice fits; otherwise
continue trimming or return a failed CompactionOutcome as appropriate. Keep the
existing adjust_for_tool_pairs and first-message preservation logic, but ensure
the final CompactionOutcome fields tokens_after, tokens_saved, and success
reflect the actual budget result.
src/memory/builtin.rs (1)

248-283: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Filter out non-matching memories before scoring.

retrieve("unrelated", limit) currently returns entries anyway because every entry gets the baseline score; an empty query can also tag-match via contains(""). Return an empty result for empty/no-match queries so callers only receive relevant memory.

Suggested fix
-        let query = query.to_string();
+        let query = query.trim().to_string();
         Box::pin(async move {
             let query_lower = query.to_lowercase();
             let query_words: Vec<&str> = query_lower.split_whitespace().collect();
+            if limit == 0 || query_words.is_empty() {
+                return Ok(Vec::new());
+            }
 
             let entries = self.entries.read().unwrap_or_else(PoisonError::into_inner);
             let mut scored: Vec<(f32, MemoryEntry)> = entries
                 .iter()
-                .map(|entry| {
+                .filter_map(|entry| {
                     let memory_lower = entry.memory.to_lowercase();
                     let tag_match = entry
                         .tags
                         .iter()
                         .any(|t| t.to_lowercase().contains(&query_lower));
                     let word_matches = query_words
                         .iter()
                         .filter(|w| memory_lower.contains(*w))
                         .count();
+                    if word_matches == 0 && !tag_match {
+                        return None;
+                    }
                     let base_score = entry.relevance;
                     #[allow(clippy::cast_precision_loss)]
                     let query_bonus = if word_matches > 0 {
                         word_matches as f32 / query_words.len().max(1) as f32
                     } else {
                         0.0
                     };
                     let tag_bonus = if tag_match { 0.3 } else { 0.0 };
-                    (
+                    Some((
                         base_score * 0.5 + query_bonus * 0.4 + tag_bonus + 0.1,
                         entry.clone(),
-                    )
+                    ))
                 })
                 .collect();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/memory/builtin.rs` around lines 248 - 283, The retrieval logic in builtin
memory scoring is too permissive because every entry gets a baseline score in
the `retrieve` flow, and `contains("")` can make tag matching succeed for empty
queries. Update the `retrieve` implementation to short-circuit empty queries and
filter out entries that have neither a tag match nor any word match before
scoring, so `MemoryEntry` results are only returned from relevant matches.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@examples/chat.rs`:
- Around line 252-258: Reject malformed arithmetic in simple_eval by ensuring
the full token stream is consumed after parse_expr, and treat any leftover
tokens as an error instead of formatting the parsed prefix. Also tighten
parse_factor so an opening parenthesis must be matched by a closing parenthesis
before returning a value; if it is missing, return an error. Update the
simple_eval flow to surface these parse failures clearly using the existing
parse_expr and parse_factor paths.

In `@README.md`:
- Around line 80-112: The runnable README examples using BareLoop currently call
run without bringing the Loop trait into scope, so uncomments will fail to
compile. Update the example imports near BareLoop and ToolRegistry to include
loopctl::engine::Loop, matching the explicit trait import used in
examples/repl-cli.rs, so the agent.run(...) calls resolve correctly.
- Around line 142-150: The Feature Flags table in README.md is outdated and is
missing the newly added provider-related flags. Update the table to include the
new flags defined in Cargo.toml—providers, openai, anthropic, ollama, deepseek,
grok, gemini, and zai—so readers can discover the corresponding modules and
examples; keep the existing entries and align the new rows with the style used
by hooks, testing, tool_health, and tool_shield.

In `@src/api/error.rs`:
- Around line 1309-1314: The test_result_type helper currently returns String,
so it no longer validates the crate’s public Result<T> alias. Update
test_result_type in src/api/error.rs to use the Result<T> alias from the module
being tested, with a path that keeps the alias in the function signature and
return value so the test actually covers it. Keep the assertion behavior the
same, but make sure the referenced type is the public Result<T> alias rather
than std::string::String.

In `@src/config.rs`:
- Around line 132-135: The config validation in the model check still lets
whitespace-only values through because `is_empty()` is used directly. Update the
validation in `src/config.rs` around the `self.model` guard to trim the value
before checking so `"   "` is rejected the same as an empty string. Also add a
regression test covering a whitespace-only model name to verify the `Config`
validation fails as expected.

In `@src/detection/convergence.rs`:
- Around line 67-74: The doc comment in convergence detection is inaccurate
about how Jaccard works in `compute_similarity`/`ConvergenceDetector`: word
reordering does not change set-based similarity, and punctuation sensitivity is
overstated because non-alphanumeric characters are normalized before
tokenization. Update the bullet list in `src/detection/convergence.rs` to keep
the semantic-blindness note, remove or reword the word-order limitation, and
adjust the punctuation/whitespace note to reflect the actual normalization
behavior.
- Around line 567-593: The streak logic in the response handling path is using
any_similar across the entire window, so non-consecutive matches can incorrectly
increment the consecutive count. Update the add_response logic in the
convergence detector to base streak extension only on the immediately previous
response (using the existing similarity check and window ordering), and keep
resetting similar_responses/consecutive_count when the last response is not
similar rather than when any earlier item in the window matches.

In `@src/engine/bare/emission.rs`:
- Around line 60-64: The terminal reason mapping in the emission path is too
coarse because `SessionEndReason` can be `Cancelled`, `MaxTurns`, or
`ContextOverflow`, but the current logic in the session-ending hook context
reduces every failure to `Error`. Update the `emit`/hook-context flow in
`bare::emission` so it receives and forwards the actual terminal reason from the
session result instead of deriving it from `result.success`, and make sure the
`SessionEndReason` value is preserved when constructing the hook context.

In `@src/engine/loop_core.rs`:
- Around line 365-377: The InputFix handling in `CorrectionType::InputFix`
currently accepts any `modified_input` value, but `ToolCall::input` must remain
a JSON object. Update this branch in `loop_core.rs` to validate
`correction.modified_input` is an object before assigning it to `self.input`,
and return `crate::reflection::CorrectionResult::Failed` when it is a scalar,
array, or missing. Keep the existing `tracing::debug!` and `self.input =
modified.clone()` path only for valid object payloads.

In `@src/middleware.rs`:
- Around line 1202-1209: The multipart test currently uses if let on
ToolContentPart::Text, which can silently succeed when a part has the wrong
variant. Update the multipart assertion block in the test around
ToolContent::Multipart to use match on parts[0] and parts[1] so any non-Text
variant causes an immediate test failure, while still asserting the expected
text values for the Text case.

In `@src/middleware/output_limit.rs`:
- Around line 65-74: The multipart handling in OutputLimit middleware currently
truncates each Text part independently, so the total tool output can still
exceed max_chars. Update ToolContent::Multipart processing in output_limit.rs to
track a shared remaining character budget across all parts, decrementing it as
you append/truncate each ToolContentPart::Text. Use the existing
ToolContent::Multipart and Text part loop to enforce one global limit for the
entire multipart payload, not per-part truncation.

---

Outside diff comments:
In `@src/compact/truncating.rs`:
- Around line 118-163: The compact() implementation in Truncating compaction
ignores the requested target_tokens and can return success even when the
preserved messages still exceed the budget. Update compact() to compare the
estimated tokens for the final preserved slice against target_tokens, and only
report success when the slice fits; otherwise continue trimming or return a
failed CompactionOutcome as appropriate. Keep the existing adjust_for_tool_pairs
and first-message preservation logic, but ensure the final CompactionOutcome
fields tokens_after, tokens_saved, and success reflect the actual budget result.

In `@src/memory/builtin.rs`:
- Around line 248-283: The retrieval logic in builtin memory scoring is too
permissive because every entry gets a baseline score in the `retrieve` flow, and
`contains("")` can make tag matching succeed for empty queries. Update the
`retrieve` implementation to short-circuit empty queries and filter out entries
that have neither a tag match nor any word match before scoring, so
`MemoryEntry` results are only returned from relevant matches.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 490d276b-0f94-4411-9ebe-0f7f45eaf3ed

📥 Commits

Reviewing files that changed from the base of the PR and between 2ac4687 and 7bcb1e8.

📒 Files selected for processing (54)
  • .github/workflows/ci.yml
  • Cargo.toml
  • Makefile
  • README.md
  • examples/chat.rs
  • examples/echo-tool-cli.rs
  • examples/hello-cli.rs
  • examples/repl-cli.rs
  • src/api.rs
  • src/api/error.rs
  • src/builder.rs
  • src/builder/error.rs
  • src/builder/features.rs
  • src/cancel.rs
  • src/capabilities.rs
  • src/compact.rs
  • src/compact/truncating.rs
  • src/config.rs
  • src/detection/convergence.rs
  • src/detection/loop_detector.rs
  • src/detection/manager.rs
  • src/engine/bare.rs
  • src/engine/bare/compact.rs
  • src/engine/bare/dispatch.rs
  • src/engine/bare/emission.rs
  • src/engine/bare/message.rs
  • src/engine/bare/stream.rs
  • src/engine/loop_core.rs
  • src/fallback.rs
  • src/hooks/executor.rs
  • src/lib.rs
  • src/memory.rs
  • src/memory/builtin.rs
  • src/message.rs
  • src/middleware.rs
  • src/middleware/output_limit.rs
  • src/middleware/permission.rs
  • src/middleware/timeout.rs
  • src/middleware/unknown_tool.rs
  • src/provider.rs
  • src/provider/anthropic.rs
  • src/provider/gemini.rs
  • src/provider/openai.rs
  • src/reflection.rs
  • src/reflection/backoff.rs
  • src/runtime.rs
  • src/stream.rs
  • src/stream/handler.rs
  • src/stream/heartbeat.rs
  • src/testing.rs
  • src/tool.rs
  • src/tool/health.rs
  • src/tool/registry.rs
  • src/tool/shield.rs
💤 Files with no reviewable changes (3)
  • src/builder/features.rs
  • src/builder.rs
  • src/builder/error.rs
🚧 Files skipped from review as they are similar to previous changes (13)
  • src/engine/bare/compact.rs
  • src/message.rs
  • src/middleware/timeout.rs
  • src/engine/bare/stream.rs
  • src/capabilities.rs
  • src/api.rs
  • src/middleware/permission.rs
  • src/middleware/unknown_tool.rs
  • src/fallback.rs
  • src/compact.rs
  • src/detection/manager.rs
  • src/engine/bare/dispatch.rs
  • src/detection/loop_detector.rs

Comment thread examples/chat.rs
Comment thread README.md
Comment thread README.md
Comment thread src/api/error.rs Outdated
Comment thread src/config.rs Outdated
Comment thread src/detection/convergence.rs
Comment thread src/engine/bare/emission.rs Outdated
Comment thread src/engine/loop_core.rs
Comment thread src/middleware.rs
Comment thread src/middleware/output_limit.rs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/api.rs (1)

160-163: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Fix the messages doc on create_message. The 'static stream note is copied from stream_messages; create_message returns a + '_ future, so this should describe taking ownership for an owned request body instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api.rs` around lines 160 - 163, The `create_message` doc comment in
`api::create_message` is using the wrong ownership rationale: remove the
`stream_messages`-specific `'static` stream note and rewrite it to explain that
`messages` is taken by ownership because the request body must be built from
owned data for the returned `+ '_` future. Keep the focus on `create_message`
and its `messages` parameter, and make sure the wording no longer mentions the
stream lifetime requirement.
src/memory.rs (1)

144-172: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Remove the Arc<dyn LoopMemory> claim or switch back to boxed futures
impl Future is fine on rust-version 1.85, but it makes LoopMemory non-dyn-compatible, so the Arc<dyn LoopMemory> wording in src/memory/builtin.rs is misleading. If trait objects are required, these methods need boxed futures; otherwise update the docs/examples to Arc<InMemoryStore> or Arc<impl LoopMemory>.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/memory.rs` around lines 144 - 172, The `LoopMemory` trait methods
`store`, `retrieve`, and `consolidate` use `impl Future`, which makes the trait
not dyn-compatible, so the `Arc<dyn LoopMemory>` wording is misleading. Either
switch the trait methods back to boxed futures if trait objects are required, or
update the `LoopMemory` docs/examples (including the references in
`memory/builtin.rs`) to use `Arc<impl LoopMemory>` or a concrete type like
`Arc<InMemoryStore>`.
🧹 Nitpick comments (1)
src/memory/builtin.rs (1)

250-280: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Keep the read lock scoped to the snapshot only.

retrieve does O(n) scoring plus O(n log n) sorting while the read guard remains in scope. Snapshot the entries under the lock, then score/sort outside it so store/consolidate are blocked for less time.

Suggested refactor
-            let entries = self.entries.read().unwrap_or_else(PoisonError::into_inner);
+            let entries = {
+                let entries = self.entries.read().unwrap_or_else(PoisonError::into_inner);
+                entries.clone()
+            };
             let mut scored: Vec<(f32, MemoryEntry)> = entries
                 .iter()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/memory/builtin.rs` around lines 250 - 280, The `retrieve` logic in
`builtin.rs` holds the `self.entries` read guard while doing the full scoring
and sorting work, which unnecessarily blocks writers. Update the `retrieve` path
to first take a snapshot of the entries from `self.entries` under the lock, then
drop the guard before the O(n) scoring and `scored.sort_by` work; keep the rest
of the ranking and `take(limit)` behavior unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/api.rs`:
- Around line 160-163: The `create_message` doc comment in `api::create_message`
is using the wrong ownership rationale: remove the `stream_messages`-specific
`'static` stream note and rewrite it to explain that `messages` is taken by
ownership because the request body must be built from owned data for the
returned `+ '_` future. Keep the focus on `create_message` and its `messages`
parameter, and make sure the wording no longer mentions the stream lifetime
requirement.

In `@src/memory.rs`:
- Around line 144-172: The `LoopMemory` trait methods `store`, `retrieve`, and
`consolidate` use `impl Future`, which makes the trait not dyn-compatible, so
the `Arc<dyn LoopMemory>` wording is misleading. Either switch the trait methods
back to boxed futures if trait objects are required, or update the `LoopMemory`
docs/examples (including the references in `memory/builtin.rs`) to use `Arc<impl
LoopMemory>` or a concrete type like `Arc<InMemoryStore>`.

---

Nitpick comments:
In `@src/memory/builtin.rs`:
- Around line 250-280: The `retrieve` logic in `builtin.rs` holds the
`self.entries` read guard while doing the full scoring and sorting work, which
unnecessarily blocks writers. Update the `retrieve` path to first take a
snapshot of the entries from `self.entries` under the lock, then drop the guard
before the O(n) scoring and `scored.sort_by` work; keep the rest of the ranking
and `take(limit)` behavior unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: fb053996-9172-4ac9-8a11-ea0b9da608fd

📥 Commits

Reviewing files that changed from the base of the PR and between 7bcb1e8 and 463338b.

📒 Files selected for processing (27)
  • Cargo.toml
  • src/api.rs
  • src/api/error.rs
  • src/compact/truncating.rs
  • src/detection/loop_detector.rs
  • src/detection/manager.rs
  • src/engine/bare.rs
  • src/engine/bare/dispatch.rs
  • src/engine/loop_core.rs
  • src/fallback.rs
  • src/memory.rs
  • src/memory/builtin.rs
  • src/message.rs
  • src/middleware.rs
  • src/middleware/unknown_tool.rs
  • src/observer.rs
  • src/observer/context.rs
  • src/provider.rs
  • src/provider/anthropic.rs
  • src/provider/gemini.rs
  • src/provider/openai.rs
  • src/reflection.rs
  • src/stream/handler.rs
  • src/testing.rs
  • src/tool/health.rs
  • src/tool/registry.rs
  • src/tool/shield.rs
💤 Files with no reviewable changes (5)
  • src/compact/truncating.rs
  • src/message.rs
  • src/middleware.rs
  • src/detection/loop_detector.rs
  • src/api/error.rs
🚧 Files skipped from review as they are similar to previous changes (5)
  • Cargo.toml
  • src/middleware/unknown_tool.rs
  • src/engine/loop_core.rs
  • src/engine/bare/dispatch.rs
  • src/fallback.rs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/detection/convergence.rs (1)

533-536: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Doc no longer matches the new streak logic.

The updated add_response derives the streak from the immediately previous response (window.back()), not "any comparison" across the whole window, and it uses >= (not "exceeds"). This doc describes the old behavior and will mislead callers.

📝 Proposed doc fix
-    /// Primary entry point. The response is compared against
-    /// every prior response in the window. If any comparison exceeds
-    /// [`ConvergenceConfig::similarity_threshold`], the consecutive count
-    /// is incremented; otherwise it resets to `1`.
+    /// Primary entry point. The response is compared against the
+    /// immediately previous response in the window. If that comparison
+    /// meets or exceeds [`ConvergenceConfig::similarity_threshold`], the
+    /// consecutive count is incremented; otherwise it resets to `1`.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/detection/convergence.rs` around lines 533 - 536, Update the doc comment
on the response-streak logic in convergence.rs to match the current add_response
behavior: it should describe comparing only against the immediately previous
response via window.back(), and state that the streak increments when similarity
is at or above ConvergenceConfig::similarity_threshold rather than “any
comparison” or “exceeds.” Keep the wording aligned with the actual streak
reset/increment behavior so the documentation for add_response and the
surrounding convergence window logic is accurate.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/detection/convergence.rs`:
- Around line 533-536: Update the doc comment on the response-streak logic in
convergence.rs to match the current add_response behavior: it should describe
comparing only against the immediately previous response via window.back(), and
state that the streak increments when similarity is at or above
ConvergenceConfig::similarity_threshold rather than “any comparison” or
“exceeds.” Keep the wording aligned with the actual streak reset/increment
behavior so the documentation for add_response and the surrounding convergence
window logic is accurate.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 68559c09-6ab9-4876-957c-589668963aa2

📥 Commits

Reviewing files that changed from the base of the PR and between 463338b and 5fd7df9.

📒 Files selected for processing (16)
  • README.md
  • examples/chat.rs
  • src/api/error.rs
  • src/config.rs
  • src/detection/convergence.rs
  • src/engine/bare.rs
  • src/engine/bare/dispatch.rs
  • src/engine/bare/emission.rs
  • src/engine/loop_core.rs
  • src/lib.rs
  • src/middleware.rs
  • src/middleware/output_limit.rs
  • src/middleware/tool_call.rs
  • src/provider/anthropic.rs
  • src/provider/gemini.rs
  • src/provider/openai.rs
✅ Files skipped from review due to trivial changes (1)
  • README.md
🚧 Files skipped from review as they are similar to previous changes (8)
  • src/config.rs
  • src/engine/bare/emission.rs
  • src/lib.rs
  • examples/chat.rs
  • src/engine/loop_core.rs
  • src/engine/bare/dispatch.rs
  • src/api/error.rs
  • src/middleware.rs

@bobrykov
bobrykov merged commit c69a39f into master Jul 1, 2026
6 checks passed
@bobrykov
bobrykov deleted the refactor/events branch July 1, 2026 06:34
This was referenced Jul 17, 2026
bobrykov added a commit that referenced this pull request Aug 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant