use std::sync::Arc;
pub mod context;
pub use context::{
CompactedContext, ConvergenceDetectedContext, FallbackContext, LoopDetectedContext,
ModelSwitchedContext, ResponseContext, RunEndContext, RunStartContext, StreamContext,
StreamFailureContext, TextDeltaContext, ThinkingDeltaContext, ToolCallReceivedContext,
ToolPostContext, ToolPreContext, TurnEndContext, TurnStartContext,
};
pub trait LoopObserver: Send + Sync {
fn name(&self) -> &str;
fn on_run_start(&self, _ctx: &RunStartContext) {}
fn on_run_end(&self, _ctx: &RunEndContext) {}
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_text_delta(&self, _ctx: &TextDeltaContext) {}
fn on_thinking_delta(&self, _ctx: &ThinkingDeltaContext) {}
fn on_tool_call_received(&self, _ctx: &ToolCallReceivedContext) {}
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);
}
fn dispatch<F>(&self, f: F)
where
F: Fn(&dyn LoopObserver),
{
use std::panic::{AssertUnwindSafe, catch_unwind};
for obs in &self.observers {
let obs: &dyn LoopObserver = obs.as_ref();
if let Err(payload) = catch_unwind(AssertUnwindSafe(|| f(obs))) {
let msg = payload
.downcast_ref::<&'static str>()
.map(|s| (*s).to_string())
.or_else(|| payload.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "non-string panic payload".to_string());
tracing::error!(
observer = obs.name(),
panic_message = %msg,
"observer panicked; continuing with remaining observers"
);
}
}
}
#[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) {
self.dispatch(|obs| obs.reset());
}
pub fn on_run_start(&self, ctx: &RunStartContext) {
self.dispatch(|obs| obs.on_run_start(ctx));
}
pub fn on_run_end(&self, ctx: &RunEndContext) {
self.dispatch(|obs| obs.on_run_end(ctx));
}
pub fn on_turn_start(&self, ctx: &TurnStartContext) {
self.dispatch(|obs| obs.on_turn_start(ctx));
}
pub fn on_turn_end(&self, ctx: &TurnEndContext) {
self.dispatch(|obs| obs.on_turn_end(ctx));
}
pub fn on_stream_success(&self, ctx: &StreamContext) {
self.dispatch(|obs| obs.on_stream_success(ctx));
}
pub fn on_stream_failure(&self, ctx: &StreamFailureContext) {
self.dispatch(|obs| obs.on_stream_failure(ctx));
}
pub fn on_response(&self, ctx: &ResponseContext) {
self.dispatch(|obs| obs.on_response(ctx));
}
pub fn on_text_delta(&self, ctx: &TextDeltaContext) {
self.dispatch(|obs| obs.on_text_delta(ctx));
}
pub fn on_thinking_delta(&self, ctx: &ThinkingDeltaContext) {
self.dispatch(|obs| obs.on_thinking_delta(ctx));
}
pub fn on_tool_pre(&self, ctx: &ToolPreContext) {
self.dispatch(|obs| obs.on_tool_pre(ctx));
}
pub fn on_tool_call_received(&self, ctx: &ToolCallReceivedContext) {
self.dispatch(|obs| obs.on_tool_call_received(ctx));
}
pub fn on_tool_post(&self, ctx: &ToolPostContext) {
self.dispatch(|obs| obs.on_tool_post(ctx));
}
pub fn on_compaction(&self, ctx: &CompactedContext) {
self.dispatch(|obs| obs.on_compaction(ctx));
}
pub fn on_fallback(&self, ctx: &FallbackContext) {
self.dispatch(|obs| obs.on_fallback(ctx));
}
pub fn on_model_switched(&self, ctx: &ModelSwitchedContext) {
self.dispatch(|obs| obs.on_model_switched(ctx));
}
pub fn on_loop_detected(&self, ctx: &LoopDetectedContext) {
self.dispatch(|obs| obs.on_loop_detected(ctx));
}
pub fn on_convergence_detected(&self, ctx: &ConvergenceDetectedContext) {
self.dispatch(|obs| 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: std::sync::Mutex<Vec<(String, String)>>,
}
impl LoopObserver for SwitchRecorder {
fn name(&self) -> &'static str {
"switch-recorder"
}
fn on_model_switched(&self, ctx: &ModelSwitchedContext) {
crate::error::recover_guard(self.events.lock())
.push((ctx.from.clone(), ctx.to.clone()));
}
}
let obs = Arc::new(SwitchRecorder {
events: std::sync::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 = crate::error::recover_guard(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(),
});
}
#[test]
fn on_text_delta_default_is_noop() {
struct NoopObserver;
impl LoopObserver for NoopObserver {
fn name(&self) -> &'static str {
"noop"
}
}
let obs = NoopObserver;
let mut host = ObserverHost::new();
host.register(Arc::new(obs) as Arc<dyn LoopObserver>);
host.on_text_delta(&TextDeltaContext {
turn: 0,
delta: "x".into(),
});
}
#[test]
fn host_dispatches_on_text_delta_to_all_observers() {
struct DeltaRecorder {
deltas: std::sync::Mutex<Vec<String>>,
}
impl LoopObserver for DeltaRecorder {
fn name(&self) -> &'static str {
"delta-recorder"
}
fn on_text_delta(&self, ctx: &TextDeltaContext) {
crate::error::recover_guard(self.deltas.lock()).push(ctx.delta.clone());
}
}
let obs1 = Arc::new(DeltaRecorder {
deltas: std::sync::Mutex::new(Vec::new()),
});
let obs2 = Arc::new(DeltaRecorder {
deltas: std::sync::Mutex::new(Vec::new()),
});
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_text_delta(&TextDeltaContext {
turn: 0,
delta: "x".into(),
});
assert_eq!(
crate::error::recover_guard(obs1.deltas.lock()).clone(),
vec!["x".to_string()]
);
assert_eq!(
crate::error::recover_guard(obs2.deltas.lock()).clone(),
vec!["x".to_string()]
);
}
#[test]
fn host_dispatches_on_text_delta_with_no_observers() {
let host = ObserverHost::new();
host.on_text_delta(&TextDeltaContext {
turn: 0,
delta: "x".into(),
});
}
#[test]
fn on_tool_call_received_default_is_noop() {
struct NoopObserver;
impl LoopObserver for NoopObserver {
fn name(&self) -> &'static str {
"noop"
}
}
let obs = NoopObserver;
let mut host = ObserverHost::new();
host.register(Arc::new(obs) as Arc<dyn LoopObserver>);
host.on_tool_call_received(&ToolCallReceivedContext {
turn: 0,
tool: "echo".into(),
call_id: "c1".into(),
input: serde_json::Value::Null,
});
}
#[test]
fn host_dispatches_on_tool_call_received_to_all_observers() {
struct ReceivedRecorder {
calls: std::sync::Mutex<Vec<String>>,
}
impl LoopObserver for ReceivedRecorder {
fn name(&self) -> &'static str {
"received-recorder"
}
fn on_tool_call_received(&self, ctx: &ToolCallReceivedContext) {
crate::error::recover_guard(self.calls.lock()).push(ctx.tool.clone());
}
}
let obs1 = Arc::new(ReceivedRecorder {
calls: std::sync::Mutex::new(Vec::new()),
});
let obs2 = Arc::new(ReceivedRecorder {
calls: std::sync::Mutex::new(Vec::new()),
});
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_tool_call_received(&ToolCallReceivedContext {
turn: 0,
tool: "edit".into(),
call_id: "c1".into(),
input: serde_json::Value::Null,
});
assert_eq!(
crate::error::recover_guard(obs1.calls.lock()).clone(),
vec!["edit".to_string()]
);
assert_eq!(
crate::error::recover_guard(obs2.calls.lock()).clone(),
vec!["edit".to_string()]
);
}
#[test]
fn host_dispatches_on_tool_call_received_with_no_observers() {
let host = ObserverHost::new();
host.on_tool_call_received(&ToolCallReceivedContext {
turn: 0,
tool: "echo".into(),
call_id: "c1".into(),
input: serde_json::Value::Null,
});
}
#[test]
fn host_dispatches_on_thinking_delta_to_all_observers() {
struct ThinkingRecorder {
deltas: std::sync::Mutex<Vec<String>>,
}
impl LoopObserver for ThinkingRecorder {
fn name(&self) -> &'static str {
"thinking-recorder"
}
fn on_thinking_delta(&self, ctx: &ThinkingDeltaContext) {
crate::error::recover_guard(self.deltas.lock()).push(ctx.delta.clone());
}
}
let obs1 = Arc::new(ThinkingRecorder {
deltas: std::sync::Mutex::new(Vec::new()),
});
let obs2 = Arc::new(ThinkingRecorder {
deltas: std::sync::Mutex::new(Vec::new()),
});
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_thinking_delta(&ThinkingDeltaContext {
turn: 2,
delta: "reasoning".into(),
});
assert_eq!(
crate::error::recover_guard(obs1.deltas.lock()).clone(),
vec!["reasoning".to_string()]
);
assert_eq!(
crate::error::recover_guard(obs2.deltas.lock()).clone(),
vec!["reasoning".to_string()]
);
}
#[test]
fn on_thinking_delta_default_is_noop() {
struct NoopObserver;
impl LoopObserver for NoopObserver {
fn name(&self) -> &'static str {
"noop"
}
}
let obs = NoopObserver;
obs.on_thinking_delta(&ThinkingDeltaContext {
turn: 0,
delta: String::new(),
});
}
}