use std::sync::Arc;
pub mod context;
pub use context::{
CompactedContext, ConvergenceDetectedContext, FallbackContext, LoopDetectedContext,
ModelSwitchedContext, ResponseContext, SessionEndContext, SessionStartContext, StreamContext,
StreamFailureContext, ToolPostContext, ToolPreContext, TurnEndContext, TurnStartContext,
};
pub trait LoopObserver: Send + Sync {
fn name(&self) -> &str;
fn on_session_start(&self, _ctx: &SessionStartContext) {}
fn on_session_end(&self, _ctx: &SessionEndContext) {}
fn on_turn_start(&self, _ctx: &TurnStartContext) {}
fn on_turn_end(&self, _ctx: &TurnEndContext) {}
fn on_stream_success(&self, _ctx: &StreamContext) {}
fn on_stream_failure(&self, _ctx: &StreamFailureContext) {}
fn on_response(&self, _ctx: &ResponseContext) {}
fn on_tool_pre(&self, _ctx: &ToolPreContext) {}
fn on_tool_post(&self, _ctx: &ToolPostContext) {}
fn on_compaction(&self, _ctx: &CompactedContext) {}
fn on_fallback(&self, _ctx: &FallbackContext) {}
fn on_model_switched(&self, _ctx: &ModelSwitchedContext) {}
fn on_loop_detected(&self, _ctx: &LoopDetectedContext) {}
fn on_convergence_detected(&self, _ctx: &ConvergenceDetectedContext) {}
fn reset(&self) {}
}
#[derive(Default)]
pub struct ObserverHost {
observers: Vec<Arc<dyn LoopObserver>>,
}
impl ObserverHost {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn register(&mut self, observer: Arc<dyn LoopObserver>) {
self.observers.push(observer);
}
#[must_use]
pub fn len(&self) -> usize {
self.observers.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.observers.is_empty()
}
pub fn reset_all(&self) {
for obs in &self.observers {
obs.reset();
}
}
pub fn on_session_start(&self, ctx: &SessionStartContext) {
for obs in &self.observers {
obs.on_session_start(ctx);
}
}
pub fn on_session_end(&self, ctx: &SessionEndContext) {
for obs in &self.observers {
obs.on_session_end(ctx);
}
}
pub fn on_turn_start(&self, ctx: &TurnStartContext) {
for obs in &self.observers {
obs.on_turn_start(ctx);
}
}
pub fn on_turn_end(&self, ctx: &TurnEndContext) {
for obs in &self.observers {
obs.on_turn_end(ctx);
}
}
pub fn on_stream_success(&self, ctx: &StreamContext) {
for obs in &self.observers {
obs.on_stream_success(ctx);
}
}
pub fn on_stream_failure(&self, ctx: &StreamFailureContext) {
for obs in &self.observers {
obs.on_stream_failure(ctx);
}
}
pub fn on_response(&self, ctx: &ResponseContext) {
for obs in &self.observers {
obs.on_response(ctx);
}
}
pub fn on_tool_pre(&self, ctx: &ToolPreContext) {
for obs in &self.observers {
obs.on_tool_pre(ctx);
}
}
pub fn on_tool_post(&self, ctx: &ToolPostContext) {
for obs in &self.observers {
obs.on_tool_post(ctx);
}
}
pub fn on_compaction(&self, ctx: &CompactedContext) {
for obs in &self.observers {
obs.on_compaction(ctx);
}
}
pub fn on_fallback(&self, ctx: &FallbackContext) {
for obs in &self.observers {
obs.on_fallback(ctx);
}
}
pub fn on_model_switched(&self, ctx: &ModelSwitchedContext) {
for obs in &self.observers {
obs.on_model_switched(ctx);
}
}
pub fn on_loop_detected(&self, ctx: &LoopDetectedContext) {
for obs in &self.observers {
obs.on_loop_detected(ctx);
}
}
pub fn on_convergence_detected(&self, ctx: &ConvergenceDetectedContext) {
for obs in &self.observers {
obs.on_convergence_detected(ctx);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
struct CountingObserver {
name: &'static str,
stream_success: AtomicUsize,
resets: AtomicUsize,
}
impl CountingObserver {
fn new(name: &'static str) -> Self {
Self {
name,
stream_success: AtomicUsize::new(0),
resets: AtomicUsize::new(0),
}
}
}
impl LoopObserver for CountingObserver {
fn name(&self) -> &str {
self.name
}
fn on_stream_success(&self, _ctx: &StreamContext) {
self.stream_success.fetch_add(1, Ordering::SeqCst);
}
fn reset(&self) {
self.resets.fetch_add(1, Ordering::SeqCst);
}
}
#[test]
fn host_dispatches_to_single_observer() {
let obs = Arc::new(CountingObserver::new("test"));
let mut host = ObserverHost::new();
host.register(Arc::clone(&obs) as Arc<dyn LoopObserver>);
host.on_stream_success(&StreamContext {
turn: 0,
model: "m".into(),
input_tokens: 0,
output_tokens: 0,
});
assert_eq!(obs.stream_success.load(Ordering::SeqCst), 1);
}
#[test]
fn host_dispatches_to_multiple_observers() {
let obs1 = Arc::new(CountingObserver::new("a"));
let obs2 = Arc::new(CountingObserver::new("b"));
let mut host = ObserverHost::new();
host.register(Arc::clone(&obs1) as Arc<dyn LoopObserver>);
host.register(Arc::clone(&obs2) as Arc<dyn LoopObserver>);
host.on_stream_success(&StreamContext {
turn: 0,
model: "m".into(),
input_tokens: 0,
output_tokens: 0,
});
assert_eq!(obs1.stream_success.load(Ordering::SeqCst), 1);
assert_eq!(obs2.stream_success.load(Ordering::SeqCst), 1);
}
#[test]
fn host_len_and_is_empty() {
let mut host = ObserverHost::new();
assert!(host.is_empty());
assert_eq!(host.len(), 0);
host.register(Arc::new(CountingObserver::new("x")) as Arc<dyn LoopObserver>);
assert!(!host.is_empty());
assert_eq!(host.len(), 1);
}
#[test]
fn host_reset_all() {
let obs = Arc::new(CountingObserver::new("p"));
let mut host = ObserverHost::new();
host.register(Arc::clone(&obs) as Arc<dyn LoopObserver>);
host.on_stream_success(&StreamContext {
turn: 0,
model: "m".into(),
input_tokens: 0,
output_tokens: 0,
});
assert_eq!(obs.stream_success.load(Ordering::SeqCst), 1);
host.reset_all();
assert_eq!(obs.resets.load(Ordering::SeqCst), 1);
}
#[test]
fn host_dispatches_model_switched() {
struct SwitchRecorder {
events: parking_lot::Mutex<Vec<(String, String)>>,
}
impl LoopObserver for SwitchRecorder {
fn name(&self) -> &'static str {
"switch-recorder"
}
fn on_model_switched(&self, ctx: &ModelSwitchedContext) {
self.events.lock().push((ctx.from.clone(), ctx.to.clone()));
}
}
let obs = Arc::new(SwitchRecorder {
events: parking_lot::Mutex::new(Vec::new()),
});
let mut host = ObserverHost::new();
host.register(Arc::clone(&obs) as Arc<dyn LoopObserver>);
host.on_model_switched(&ModelSwitchedContext {
from: "a".into(),
to: "b".into(),
});
host.on_model_switched(&ModelSwitchedContext {
from: "b".into(),
to: "c".into(),
});
let events = obs.events.lock();
assert_eq!(events.len(), 2);
assert_eq!(events[0], ("a".into(), "b".into()));
assert_eq!(events[1], ("b".into(), "c".into()));
}
#[test]
fn model_switched_default_is_noop() {
struct NoopObserver;
impl LoopObserver for NoopObserver {
fn name(&self) -> &'static str {
"noop"
}
}
let obs = NoopObserver;
obs.on_model_switched(&ModelSwitchedContext {
from: "x".into(),
to: "y".into(),
});
}
}