use std::sync::Arc;
use crate::compact::ContextManager;
use crate::detection::DetectionManager;
use crate::fallback::FallbackManager;
#[cfg(feature = "hooks")]
use crate::hooks::HookExecutor;
use crate::middleware::ToolPipeline;
use crate::observer::{LoopObserver, ObserverHost};
use crate::stream::handler::StreamHandler;
#[cfg(feature = "tool_health")]
use crate::tool::health::ToolHealthRegistry;
pub use crate::capabilities::*;
pub struct LoopRuntime {
pub fallback: FallbackManager,
pub detection: DetectionManager,
observer_host: ObserverHost,
tool_pipeline: Option<ToolPipeline>,
context_manager: Option<Arc<ContextManager>>,
stream_handler: Option<StreamHandler>,
#[cfg(feature = "hooks")]
hook_executor: Option<Arc<HookExecutor>>,
#[cfg(feature = "tool_health")]
health_registry: Option<Arc<ToolHealthRegistry>>,
}
#[must_use]
pub struct LoopRuntimeBuilder {
inner: LoopRuntime,
}
impl LoopRuntimeBuilder {
pub fn with_fallback(mut self, fallback: FallbackManager) -> Self {
self.inner.fallback = fallback;
self
}
pub fn with_detection(mut self, detection: DetectionManager) -> Self {
self.inner.detection = detection;
self
}
pub fn with_observer(mut self, observer: Arc<dyn LoopObserver>) -> Self {
self.inner.observer_host.register(observer);
self
}
pub fn with_pipeline(mut self, pipeline: ToolPipeline) -> Self {
self.inner.tool_pipeline = Some(pipeline);
self
}
pub fn with_context_manager(mut self, manager: Arc<ContextManager>) -> Self {
self.inner.context_manager = Some(manager);
self
}
pub fn with_stream_handler(mut self, handler: StreamHandler) -> Self {
self.inner.stream_handler = Some(handler);
self
}
#[cfg(feature = "hooks")]
pub fn with_hook_executor(mut self, executor: Arc<HookExecutor>) -> Self {
self.inner.hook_executor = Some(executor);
self
}
#[cfg(feature = "tool_health")]
pub fn with_health_registry(mut self, registry: Arc<ToolHealthRegistry>) -> Self {
self.inner.health_registry = Some(registry);
self
}
pub fn build(self) -> LoopRuntime {
self.inner
}
}
impl LoopRuntime {
#[must_use]
pub fn new() -> Self {
Self {
fallback: FallbackManager::default(),
detection: DetectionManager::default(),
observer_host: ObserverHost::new(),
tool_pipeline: None,
context_manager: None,
stream_handler: None,
#[cfg(feature = "hooks")]
hook_executor: None,
#[cfg(feature = "tool_health")]
health_registry: None,
}
}
pub fn builder() -> LoopRuntimeBuilder {
LoopRuntimeBuilder { inner: Self::new() }
}
#[must_use]
pub fn with_fallback(mut self, fallback: FallbackManager) -> Self {
self.fallback = fallback;
self
}
#[must_use]
pub fn with_detection(mut self, detection: DetectionManager) -> Self {
self.detection = detection;
self
}
pub fn register_observer(&mut self, observer: Arc<dyn LoopObserver>) {
self.observer_host.register(observer);
}
#[must_use]
pub fn with_observer(mut self, observer: Arc<dyn LoopObserver>) -> Self {
self.observer_host.register(observer);
self
}
pub fn observers(&self) -> &ObserverHost {
&self.observer_host
}
#[must_use]
pub fn with_pipeline(mut self, pipeline: ToolPipeline) -> Self {
self.tool_pipeline = Some(pipeline);
self
}
pub fn set_pipeline(&mut self, pipeline: ToolPipeline) {
self.tool_pipeline = Some(pipeline);
}
#[must_use]
pub fn with_context_manager(mut self, manager: Arc<ContextManager>) -> Self {
self.context_manager = Some(manager);
self
}
pub fn set_context_manager(&mut self, manager: Arc<ContextManager>) {
self.context_manager = Some(manager);
}
#[must_use]
pub fn with_stream_handler(mut self, handler: StreamHandler) -> Self {
self.stream_handler = Some(handler);
self
}
pub fn set_stream_handler(&mut self, handler: StreamHandler) {
self.stream_handler = Some(handler);
}
#[must_use]
#[cfg(feature = "hooks")]
pub fn with_hook_executor(mut self, executor: Arc<HookExecutor>) -> Self {
self.hook_executor = Some(executor);
self
}
#[cfg(feature = "hooks")]
pub fn set_hook_executor(&mut self, executor: Arc<HookExecutor>) {
self.hook_executor = Some(executor);
}
#[must_use]
#[cfg(feature = "tool_health")]
pub fn with_health_registry(mut self, registry: Arc<ToolHealthRegistry>) -> Self {
self.health_registry = Some(registry);
self
}
#[cfg(feature = "tool_health")]
pub fn set_health_registry(&mut self, registry: Arc<ToolHealthRegistry>) {
self.health_registry = Some(registry);
}
pub fn reset_all(&self) {
self.fallback.reset();
self.detection.reset();
self.observer_host.reset_all();
}
pub fn handle_detected_pattern(
&self,
pattern: &crate::detection::DetectedPattern,
turn: usize,
) -> Option<Result<crate::engine::loop_core::SessionResult, crate::error::LoopError>> {
use crate::detection::{ConvergenceAction, DetectedPattern};
use crate::error::LoopError;
use crate::observer::{ConvergenceDetectedContext, LoopDetectedContext};
match pattern {
DetectedPattern::NoPattern => None,
DetectedPattern::LoopDetected {
repetitions,
pattern_description,
} => {
tracing::warn!(
repetitions,
pattern = %pattern_description,
turn,
"loop detected"
);
self.observer_host.on_loop_detected(&LoopDetectedContext {
pattern: pattern_description.clone(),
repetitions: *repetitions,
});
if *repetitions >= self.detection.config().stop_threshold {
tracing::error!(
repetitions,
pattern = %pattern_description,
turn,
"stopping agent: loop threshold exceeded"
);
Some(Err(LoopError::LoopDetected {
message: format!("{pattern_description} repeated {repetitions} times"),
}))
} else {
None
}
}
DetectedPattern::ConvergenceDetected {
similarity,
consecutive_count,
} => {
tracing::warn!(similarity, consecutive_count, turn, "convergence detected");
let action = self.detection.config().on_converge;
let action_str = match action {
ConvergenceAction::Stop => "stop",
ConvergenceAction::Warn => "warn",
ConvergenceAction::Compact => "compact",
ConvergenceAction::AskUser => "ask_user",
ConvergenceAction::SwitchPhase => "switch_phase",
};
self.observer_host
.on_convergence_detected(&ConvergenceDetectedContext {
action: action_str.to_string(),
});
match action {
ConvergenceAction::Stop => Some(Err(LoopError::LoopDetected {
message: "agent stopped: convergence detected".into(),
})),
ConvergenceAction::AskUser => Some(Err(LoopError::LoopDetected {
message: "agent stopped: convergence detected, user input needed".into(),
})),
ConvergenceAction::Warn
| ConvergenceAction::Compact
| ConvergenceAction::SwitchPhase => None,
}
}
}
}
pub fn notify_session_start(
&self,
session_id: uuid::Uuid,
#[allow(unused_variables)] model: &str,
) {
use crate::observer::SessionStartContext;
self.observer_host
.on_session_start(&SessionStartContext { session_id });
#[cfg(feature = "hooks")]
if let Some(executor) = self.hook_executor() {
use crate::hooks::context::SessionStartContext as HookSessionStartContext;
let ctx = HookSessionStartContext {
session_id,
model: model.to_string(),
working_directory: std::env::current_dir()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default(),
};
executor.notify_session_start(&ctx);
}
}
pub fn notify_session_end(
&self,
result: &crate::engine::loop_core::SessionResult,
duration: std::time::Duration,
) {
use crate::observer::SessionEndContext;
self.observer_host.on_session_end(&SessionEndContext {
success: result.success,
error: result.error.clone(),
total_turns: result.total_turns,
duration_ms: u64::try_from(duration.as_millis()).unwrap_or(u64::MAX),
});
#[cfg(feature = "hooks")]
if let Some(executor) = self.hook_executor() {
use crate::hooks::context::{
SessionEndContext as HookSessionEndContext, SessionEndReason,
};
let reason = if result.success {
SessionEndReason::Complete
} else {
SessionEndReason::Error
};
let ctx = HookSessionEndContext {
session_id: result.session_id,
reason,
total_turns: result.total_turns,
total_tokens: result.input_tokens.saturating_add(result.output_tokens),
duration_secs: duration.as_secs(),
};
executor.notify_session_end(&ctx);
}
}
}
impl Default for LoopRuntime {
fn default() -> Self {
Self::new()
}
}
impl crate::capabilities::Observable for LoopRuntime {
fn observers(&self) -> &ObserverHost {
&self.observer_host
}
}
impl crate::capabilities::Detectable for LoopRuntime {
fn detection(&self) -> &DetectionManager {
&self.detection
}
}
impl crate::capabilities::FallbackCapable for LoopRuntime {
fn fallback(&self) -> &FallbackManager {
&self.fallback
}
}
impl crate::capabilities::Compactable for LoopRuntime {
fn context_manager(&self) -> Option<&Arc<ContextManager>> {
self.context_manager.as_ref()
}
}
impl crate::capabilities::StreamCapable for LoopRuntime {
fn stream_handler(&self) -> Option<&StreamHandler> {
self.stream_handler.as_ref()
}
}
#[cfg(feature = "hooks")]
impl crate::capabilities::Hookable for LoopRuntime {
fn hook_executor(&self) -> Option<&HookExecutor> {
self.hook_executor.as_deref()
}
}
impl crate::capabilities::PipelineAware for LoopRuntime {
fn pipeline(&self) -> Option<&ToolPipeline> {
self.tool_pipeline.as_ref()
}
}
#[cfg(feature = "tool_health")]
impl crate::capabilities::HealthTrackable for LoopRuntime {
fn health_registry(&self) -> Option<&ToolHealthRegistry> {
self.health_registry.as_deref()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::detection::{DetectedPattern, DetectionManager};
#[test]
fn test_runtime_default() {
let runtime = LoopRuntime::default();
assert!(runtime.fallback().active_model().is_none());
}
#[test]
fn test_runtime_with_custom_fallback() {
let fallback = FallbackManager::for_model("my-model");
let runtime = LoopRuntime::new().with_fallback(fallback);
assert_eq!(
runtime.fallback().active_model().as_deref(),
Some("my-model")
);
}
#[test]
fn test_reset_all() {
let runtime = LoopRuntime::new();
runtime.reset_all();
}
#[test]
fn test_runtime_contains_detection_manager() {
let runtime = LoopRuntime::new();
assert_eq!(runtime.detection().config().loop_threshold, 3);
assert_eq!(runtime.detection().config().stop_threshold, 10);
}
#[test]
fn test_runtime_with_custom_detection() {
use crate::detection::DetectionConfig;
let config = DetectionConfig {
loop_threshold: 7,
stop_threshold: 20,
..Default::default()
};
let detection = DetectionManager::new_with_config(config).unwrap();
let runtime = LoopRuntime::new().with_detection(detection);
assert_eq!(runtime.detection().config().loop_threshold, 7);
assert_eq!(runtime.detection().config().stop_threshold, 20);
assert!(runtime.fallback().active_model().is_none());
}
#[test]
fn test_reset_all_clears_detection() {
let runtime = LoopRuntime::new();
let _ = runtime.detection().record_tool_call("Read", 12345);
runtime.reset_all();
let pattern = runtime.detection().record_tool_call("Read", 12345);
assert!(matches!(pattern, DetectedPattern::NoPattern));
}
#[test]
fn test_capability_traits_are_object_safe() {
fn _assert_observable(_: &dyn Observable) {}
fn _assert_detectable(_: &dyn Detectable) {}
fn _assert_fallback(_: &dyn FallbackCapable) {}
fn _assert_pipeline(_: &dyn PipelineAware) {}
fn _assert_compactable(_: &dyn Compactable) {}
fn _assert_stream_capable(_: &dyn StreamCapable) {}
let runtime = LoopRuntime::new();
_assert_observable(&runtime);
_assert_detectable(&runtime);
_assert_fallback(&runtime);
_assert_pipeline(&runtime);
_assert_compactable(&runtime);
_assert_stream_capable(&runtime);
}
#[test]
fn test_pipeline_defaults_to_none() {
let runtime = LoopRuntime::new();
assert!(runtime.pipeline().is_none());
}
#[test]
fn test_observers_accessible_via_trait() {
let runtime = LoopRuntime::new();
let _: &ObserverHost = runtime.observers();
}
#[test]
fn test_context_manager_defaults_to_none() {
let runtime = LoopRuntime::new();
assert!(runtime.context_manager().is_none());
}
#[test]
fn test_stream_handler_defaults_to_none() {
let runtime = LoopRuntime::new();
assert!(runtime.stream_handler().is_none());
}
#[test]
fn test_set_pipeline_returns_some() {
use crate::middleware::ToolPipeline;
use crate::tool::ToolRegistry;
use std::sync::Arc;
let registry = Arc::new(ToolRegistry::new());
let pipeline = ToolPipeline::new(registry);
let mut runtime = LoopRuntime::new();
assert!(runtime.pipeline().is_none());
runtime.set_pipeline(pipeline);
assert!(runtime.pipeline().is_some());
}
#[test]
fn test_set_context_manager_returns_some() {
use crate::compact::{ContextManager, TruncatingCompactor};
let compactor = TruncatingCompactor::new();
let manager = ContextManager::new(Arc::new(compactor));
let mut runtime = LoopRuntime::new();
assert!(runtime.context_manager().is_none());
runtime.set_context_manager(Arc::new(manager));
assert!(runtime.context_manager().is_some());
}
#[test]
fn test_set_stream_handler_returns_some() {
use crate::stream::handler::StreamHandler;
let handler = StreamHandler::new();
let mut runtime = LoopRuntime::new();
assert!(runtime.stream_handler().is_none());
runtime.set_stream_handler(handler);
assert!(runtime.stream_handler().is_some());
}
#[test]
fn test_register_observer_increments_count() {
use crate::observer::LoopObserver;
use std::sync::Arc;
struct NopObserver;
impl LoopObserver for NopObserver {
fn name(&self) -> &'static str {
"NopObserver"
}
}
let mut runtime = LoopRuntime::new();
assert!(runtime.observers().is_empty());
runtime.register_observer(Arc::new(NopObserver));
assert_eq!(runtime.observers().len(), 1);
runtime.register_observer(Arc::new(NopObserver));
assert_eq!(runtime.observers().len(), 2);
}
#[test]
fn test_reset_all_clears_fallback() {
let runtime = LoopRuntime::new();
let _ = runtime.fallback().record_api_failure();
runtime.reset_all();
assert!(runtime.fallback().active_model().is_none());
}
#[test]
fn test_reset_all_clears_observers() {
use crate::observer::LoopObserver;
use std::sync::Arc;
struct NopObserver;
impl LoopObserver for NopObserver {
fn name(&self) -> &'static str {
"NopObserver"
}
}
let mut runtime = LoopRuntime::new();
runtime.register_observer(Arc::new(NopObserver));
assert_eq!(runtime.observers().len(), 1);
runtime.reset_all();
assert_eq!(runtime.observers().len(), 1);
}
#[test]
fn test_generic_bounds_accept_runtime() {
fn accepts_observable(_: &impl Observable) {}
fn accepts_detectable(_: &impl Detectable) {}
fn accepts_fallback(_: &impl FallbackCapable) {}
fn accepts_compactable(_: &impl Compactable) {}
fn accepts_stream_capable(_: &impl StreamCapable) {}
fn accepts_pipeline(_: &impl PipelineAware) {}
fn accepts_multi_bound(_: &(impl Observable + Detectable + FallbackCapable)) {}
let runtime = LoopRuntime::new();
accepts_observable(&runtime);
accepts_detectable(&runtime);
accepts_fallback(&runtime);
accepts_compactable(&runtime);
accepts_stream_capable(&runtime);
accepts_pipeline(&runtime);
accepts_multi_bound(&runtime);
}
}