diff --git a/src/builtin.rs b/src/builtin.rs new file mode 100644 index 0000000..5deb793 --- /dev/null +++ b/src/builtin.rs @@ -0,0 +1,43 @@ +//! Reference implementations of framework traits. +//! +//! This module provides ready-to-use implementations of the core traits so +//! consumers can get started without writing their own. Each implementation +//! is deliberately simple — suitable for prototyping and testing — and can +//! be replaced with domain-specific versions when needed. +//! +//! # Available Implementations +//! +//! | Implementation | Trait | Purpose | +//! |---------------------|-----------------------------------------------|--------------------------------| +//! | [`InMemoryStore`] | [`AgentMemory`](crate::core::AgentMemory) | `Vec`-backed memory store | +//! | [`NoOpObserver`] | [`AgentObserver`](crate::core::AgentObserver) | No-op (default) observer | +//! | [`LoggingObserver`] | [`AgentObserver`](crate::core::AgentObserver) | Logs all events via `tracing` | +//! | [`MultiObserver`] | [`AgentObserver`](crate::core::AgentObserver) | Fans out to multiple observers | +//! +//! # Quick Start +//! +//! ```rust +//! use loopctl::builtin::{InMemoryStore, LoggingObserver, MultiObserver, NoOpObserver}; +//! use loopctl::core::{AgentMemory, MemoryEntry, MemoryCategory}; +//! +//! # tokio::runtime::Runtime::new().unwrap().block_on(async { +//! // In-memory store for agent memories +//! let mut store = InMemoryStore::new(); +//! store.store(MemoryEntry::new(MemoryCategory::Fact, "PostgreSQL 15 is used")).await.unwrap(); +//! +//! // Logging observer +//! let observer = LoggingObserver; +//! +//! // Compose multiple observers +//! let multi = MultiObserver::new() +//! .with(LoggingObserver) +//! .with(NoOpObserver); +//! assert_eq!(multi.len(), 2); +//! # }); +//! ``` + +pub mod memory; +pub mod observer; + +pub use memory::InMemoryStore; +pub use observer::{LoggingObserver, MultiObserver, NoOpObserver}; diff --git a/src/builtin/memory.rs b/src/builtin/memory.rs new file mode 100644 index 0000000..62d5400 --- /dev/null +++ b/src/builtin/memory.rs @@ -0,0 +1,471 @@ +//! Reference memory implementation — in-memory [`AgentMemory`] backend. +//! +//! This module provides [`InMemoryStore`], a simple `Vec`-backed +//! implementation of the [`AgentMemory`] trait. It is intended for +//! testing, prototyping, and as a reference for building more +//! sophisticated memory backends (e.g. vector similarity stores). +//! +//! # Provided Implementations +//! +//! - **[`InMemoryStore`]** — Stores [`MemoryEntry`] values in a `Vec` and +//! retrieves them via weighted keyword + tag scoring. Supports +//! [`consolidate`](AgentMemory::consolidate) by pruning entries whose +//! [`relevance`](MemoryEntry::relevance) drops below 0.05. +//! +//! # When to Use +//! +//! Use this backend when you need a zero-dependency, deterministic memory +//! store — for example in unit tests, benchmarks, or single-session agents +//! that don't require persistence across restarts. For production agents +//! that need durable or distributed memory, implement [`AgentMemory`] on +//! top of a database or vector store instead. +//! +//! # Data Flow +//! +//! ```text +//! ┌──────────────────────┐ +//! agent turn ───▶ │ store(entry) │ +//! │ ▼ │ +//! │ entries: Vec<_> │ +//! │ ▼ │ +//! agent turn ◀─── │ retrieve(query, n) │ +//! │ ▼ │ +//! periodic ───▶ │ consolidate() │ +//! └──────────────────────┘ +//! ``` +//! +//! # Quick Start +//! +//! ```rust +//! use loopctl::builtin::memory::InMemoryStore; +//! use loopctl::core::{AgentMemory, MemoryEntry, MemoryCategory}; +//! +//! # tokio::runtime::Runtime::new().unwrap().block_on(async { +//! let mut store = InMemoryStore::new(); +//! +//! store.store( +//! MemoryEntry::new(MemoryCategory::Insight, "Prefer Glob over manual file search") +//! ).await.unwrap(); +//! +//! let results = store.retrieve("file search", 5).await.unwrap(); +//! assert_eq!(results.len(), 1); +//! # }); +//! ``` + +use crate::core::{AgentError, AgentMemory, ConsolidationStats, MemoryEntry}; + +/// A simple in-memory store for agent memory entries. +/// +/// Stores [`MemoryEntry`] values in a flat `Vec` and retrieves them using +/// a weighted scoring function that combines the entry's base +/// [`relevance`](MemoryEntry::relevance), word-overlap with the query, +/// and tag matching. This scoring strategy provides reasonable results +/// without requiring an embedding model. +/// +/// **Not suitable for production** — entries are held in process memory +/// and lost on crash. Use this for unit tests, integration tests, and +/// as a reference when implementing a real backend (e.g. one backed by +/// a vector database). +/// +/// # Scoring Formula +/// +/// Each candidate entry is scored during [`retrieve`](AgentMemory::retrieve) +/// using a weighted blend of three signals: +/// +/// ```text +/// final_score = relevance × 0.5 +/// + word_overlap_ratio × 0.4 +/// + tag_bonus (0.3 if any tag matches) +/// + 0.1 (baseline) +/// ``` +/// +/// The baseline term ensures that every entry has a non-zero score so +/// that even entries with no word overlap can still be returned when the +/// store is sparse. +/// +/// # Thread Safety +/// +/// [`InMemoryStore`] is `Send + Sync` because all mutation goes through +/// `&mut self` in the [`AgentMemory`] trait. If you need shared mutable +/// access from multiple tasks, wrap it in `Arc>`. +/// +/// # Construction +/// +/// ``` +/// use loopctl::builtin::memory::InMemoryStore; +/// use loopctl::core::{MemoryEntry, MemoryCategory}; +/// +/// // Empty store: +/// let store = InMemoryStore::new(); +/// +/// // Pre-populated: +/// let store = InMemoryStore::with_entries(vec![ +/// MemoryEntry::new(MemoryCategory::Fact, "The project uses Rust 1.95"), +/// ]); +/// ``` +/// +/// # Example +/// +/// ```rust +/// use loopctl::builtin::memory::InMemoryStore; +/// use loopctl::core::{AgentMemory, MemoryEntry, MemoryCategory}; +/// +/// # tokio::runtime::Runtime::new().unwrap().block_on(async { +/// let mut store = InMemoryStore::new(); +/// +/// store.store(MemoryEntry::new(MemoryCategory::Insight, "Prefer Glob over manual file search")).await.unwrap(); +/// +/// let results = store.retrieve("file search", 5).await.unwrap(); +/// assert_eq!(results.len(), 1); +/// # }); +/// ``` +pub struct InMemoryStore { + /// The internal list of stored memory entries. + /// + /// Entries are appended in insertion order via [`store`](InMemoryStore::store). + /// During [`retrieve`](InMemoryStore::retrieve) the list is scanned linearly + /// and entries are scored / sorted by relevance. During + /// [`consolidate`](InMemoryStore::consolidate), entries with + /// [`relevance`](MemoryEntry::relevance) below 0.05 are removed. + /// + /// Starts empty when created via [`new`](InMemoryStore::new) or + /// [`default`](InMemoryStore::default). Pre-populated when created + /// via [`with_entries`](InMemoryStore::with_entries). + /// + /// **Constraints:** Entries are never reordered — new items are always + /// appended to the end. Removals only occur during + /// [`consolidate`](AgentMemory::consolidate) and preserve the + /// relative order of surviving entries. + entries: Vec, +} + +// =================================================== +// Construction +// =================================================== + +impl InMemoryStore { + /// Create a new empty store. + /// + /// Returns a fresh [`InMemoryStore`] whose [`len`](AgentMemory::len) is zero. + /// + /// # Example + /// + /// ``` + /// use loopctl::builtin::memory::InMemoryStore; + /// use loopctl::core::AgentMemory; + /// + /// let store = InMemoryStore::new(); + /// assert!(store.is_empty()); + /// ``` + #[must_use] + pub fn new() -> Self { + Self { + entries: Vec::new(), + } + } + + /// Create a store pre-populated with the given entries. + /// + /// Useful for setting up test fixtures or seeding an agent with + /// prior knowledge. The entries are stored in the order provided. + /// + /// # Example + /// + /// ``` + /// use loopctl::builtin::memory::InMemoryStore; + /// use loopctl::core::{AgentMemory, MemoryEntry, MemoryCategory}; + /// + /// let store = InMemoryStore::with_entries(vec![ + /// MemoryEntry::new(MemoryCategory::Fact, "Rust 1.75 stabilised async fn in trait"), + /// MemoryEntry::new(MemoryCategory::Strategy, "Start refactors with tests"), + /// ]); + /// assert_eq!(store.len(), 2); + /// ``` + #[must_use] + pub fn with_entries(entries: Vec) -> Self { + Self { entries } + } +} + +impl Default for InMemoryStore { + /// Returns an empty store, equivalent to [`new`](InMemoryStore::new). + /// + /// Enables the [`Default`] trait so [`InMemoryStore`] can be used in + /// generic contexts that require `T: Default` (e.g. struct initialisation + /// with `..Default::default()`). + /// + /// # Example + /// + /// ``` + /// use loopctl::builtin::memory::InMemoryStore; + /// use loopctl::core::AgentMemory; + /// + /// let store = InMemoryStore::default(); + /// assert!(store.is_empty()); + /// ``` + fn default() -> Self { + Self::new() + } +} + +// =================================================== +// AgentMemory implementation +// =================================================== + +impl AgentMemory for InMemoryStore { + /// Store a new memory entry by appending it to the internal list. + /// + /// Called whenever the agent encounters information worth remembering — + /// for example after a successful tool invocation, a resolved error, or + /// an insight drawn from conversation. The entry is simply pushed onto + /// the internal entries vector. + /// + /// # Errors + /// + /// This implementation never returns an error, but the return type + /// conforms to the [`AgentMemory`] trait signature for compatibility. + async fn store(&mut self, entry: MemoryEntry) -> Result<(), AgentError> { + self.entries.push(entry); + Ok(()) + } + + /// Retrieve memory entries relevant to the given query. + /// + /// Called before each turn (or on demand) to surface context the agent + /// can use. Returns up to `limit` entries ordered by a composite score + /// that blends: + /// + /// - **Base relevance** (50%) — the entry's [`relevance`](MemoryEntry::relevance) field. + /// - **Word overlap** (40%) — fraction of query words found in the entry memory. + /// - **Tag match** (30% flat bonus) — whether any tag contains the full query. + /// - **Baseline** (10%) — ensures every entry has a non-zero score. + /// + /// The query is matched case-insensitively against both the entry + /// [`memory`](MemoryEntry::memory) and [`tags`](MemoryEntry::tags). + /// + /// # Returns + /// + /// A `Vec` of at most `limit` entries, sorted by descending + /// composite score. May be empty if no entries match or the store is empty. + /// + /// # Example + /// + /// ```rust + /// use loopctl::builtin::memory::InMemoryStore; + /// use loopctl::core::{AgentMemory, MemoryEntry, MemoryCategory}; + /// + /// # tokio::runtime::Runtime::new().unwrap().block_on(async { + /// let mut store = InMemoryStore::new(); + /// store.store(MemoryEntry::new(MemoryCategory::Fact, "file search uses Glob")).await.unwrap(); + /// + /// let results = store.retrieve("file search", 5).await.unwrap(); + /// for entry in &results { + /// println!("{:?}", entry.category); + /// } + /// # }); + /// ``` + async fn retrieve(&self, query: &str, limit: usize) -> Result, AgentError> { + let query_lower = query.to_lowercase(); + let query_words: Vec<&str> = query_lower.split_whitespace().collect(); + + let mut scored: Vec<(f32, MemoryEntry)> = self + .entries + .iter() + .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(); + 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 }; + ( + base_score * 0.5 + query_bonus * 0.4 + tag_bonus + 0.1, + entry.clone(), + ) + }) + .collect(); + + scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal)); + + Ok(scored.into_iter().take(limit).map(|(_, e)| e).collect()) + } + + /// Consolidate memory by pruning low-relevance entries. + /// + /// Called periodically by the framework to keep the memory store healthy. + /// This implementation removes entries whose + /// [`relevance`](MemoryEntry::relevance) score has decayed below 0.05. + /// It does **not** perform merging — [`merged`](ConsolidationStats::merged) + /// and [`bytes_saved`](ConsolidationStats::bytes_saved) are always zero. + /// + /// # Returns + /// + /// A [`ConsolidationStats`] describing the number of entries before and + /// after pruning, and how many were removed. + /// + /// # Example + /// + /// ```rust + /// use loopctl::builtin::memory::InMemoryStore; + /// use loopctl::core::AgentMemory; + /// + /// # tokio::runtime::Runtime::new().unwrap().block_on(async { + /// let mut store = InMemoryStore::new(); + /// let stats = store.consolidate().await.unwrap(); + /// println!("Pruned {} entries", stats.pruned); + /// # }); + /// ``` + async fn consolidate(&mut self) -> Result { + let entries_before = self.entries.len(); + self.entries.retain(|e| e.relevance >= 0.05); + let pruned = entries_before.saturating_sub(self.entries.len()); + Ok(ConsolidationStats { + entries_before, + entries_after: self.entries.len(), + pruned, + merged: 0, + bytes_saved: 0, + }) + } + + /// Number of entries currently stored. + /// + /// Returns the length of the internal entries + /// vector. Used by the framework to monitor memory usage and by the + /// [`is_empty`](AgentMemory::is_empty) provided method. + fn len(&self) -> usize { + self.entries.len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::MemoryCategory; + + #[tokio::test] + async fn test_store_and_retrieve() { + let mut store = InMemoryStore::new(); + + store + .store(MemoryEntry::new( + MemoryCategory::Insight, + "Prefer Glob over manual file search", + )) + .await + .unwrap(); + + store + .store(MemoryEntry::new( + MemoryCategory::ErrorPattern, + "Edit failures often caused by stale file content", + )) + .await + .unwrap(); + + let results = store.retrieve("Glob manual file search", 5).await.unwrap(); + assert!(!results.is_empty()); + assert_eq!(results[0].category, MemoryCategory::Insight); + } + + #[tokio::test] + async fn test_retrieve_respects_limit() { + let mut store = InMemoryStore::new(); + + for i in 0..10 { + store + .store(MemoryEntry::new( + MemoryCategory::Fact, + format!("Fact number {i} about testing"), + )) + .await + .unwrap(); + } + + let results = store.retrieve("testing", 3).await.unwrap(); + assert_eq!(results.len(), 3); + } + + #[tokio::test] + async fn test_retrieve_empty_store() { + let store = InMemoryStore::new(); + let results = store.retrieve("anything", 5).await.unwrap(); + assert!(results.is_empty()); + } + + #[tokio::test] + async fn test_len_and_is_empty() { + let store = InMemoryStore::new(); + assert!(store.is_empty()); + assert_eq!(store.len(), 0); + } + + #[tokio::test] + async fn test_consolidate_prunes_low_relevance() { + let mut store = InMemoryStore::new(); + + let mut good_entry = MemoryEntry::new(MemoryCategory::Insight, "useful insight"); + good_entry.relevance = 0.9; + store.store(good_entry).await.unwrap(); + + let mut bad_entry = MemoryEntry::new(MemoryCategory::Working, "temporary data"); + bad_entry.relevance = 0.01; + store.store(bad_entry).await.unwrap(); + + assert_eq!(store.len(), 2); + + let stats = store.consolidate().await.unwrap(); + + assert_eq!(stats.entries_before, 2); + assert_eq!(stats.pruned, 1); + assert_eq!(store.len(), 1); + } + + #[tokio::test] + async fn test_with_entries() { + let entries = vec![ + MemoryEntry::new(MemoryCategory::Fact, "fact 1"), + MemoryEntry::new(MemoryCategory::Fact, "fact 2"), + ]; + let store = InMemoryStore::with_entries(entries); + assert_eq!(store.len(), 2); + } + + #[tokio::test] + async fn test_tag_matching_boosts_relevance() { + let mut store = InMemoryStore::new(); + + let tagged = + MemoryEntry::new(MemoryCategory::Strategy, "use iterators for loops").with_tag("rust"); + store.store(tagged).await.unwrap(); + + store + .store(MemoryEntry::new( + MemoryCategory::Strategy, + "use caching for performance", + )) + .await + .unwrap(); + + let results = store.retrieve("rust iterators", 2).await.unwrap(); + assert!(!results.is_empty()); + assert!(results[0].memory.contains("iterators")); + } + + #[tokio::test] + async fn test_default_is_empty() { + let store = InMemoryStore::default(); + assert!(store.is_empty()); + } +} diff --git a/src/builtin/observer.rs b/src/builtin/observer.rs new file mode 100644 index 0000000..4502bed --- /dev/null +++ b/src/builtin/observer.rs @@ -0,0 +1,605 @@ +//! Reference observer implementations — ready-to-use [`AgentObserver`] backends. +//! +//! This module provides concrete implementations of the [`AgentObserver`] trait +//! so consumers can plug in observability without implementing the trait from +//! scratch. Each implementation covers a different use case, from silent +//! no-ops to full `tracing`-based logging to composite fan-out. +//! +//! # Provided Implementations +//! +//! - **[`NoOpObserver`]** — A zero-cost no-op that silently ignores all +//! lifecycle events. Useful as a safe default and in tests. +//! - **[`LoggingObserver`]** — Emits structured log lines for every event +//! via the `tracing` crate. Session and compaction events are logged at +//! `info` level; tool events at `debug`; errors and fallbacks at `warn`. +//! - **[`MultiObserver`]** — A composite observer that fans out every event +//! to an ordered list of inner observers. Follows the composite pattern. +//! +//! # Quick Start +//! +//! ``` +//! use loopctl::builtin::observer::{LoggingObserver, MultiObserver, NoOpObserver}; +//! use loopctl::core::AgentObserver; +//! +//! // Use a single logging observer: +//! let observer = LoggingObserver; +//! +//! // Or compose multiple observers: +//! let multi = MultiObserver::new() +//! .with(LoggingObserver) +//! .with(NoOpObserver); +//! +//! multi.on_session_start(uuid::Uuid::new_v4()); +//! ``` + +use crate::core::AgentObserver; +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::sync::Arc; +use std::time::Duration; +use tracing::warn; + +/// A no-op observer that silently ignores all events. +/// +/// Every method body is empty — no allocation, no I/O, no side effects. +/// Use this as a safe default when no observability is needed, as a +/// placeholder in tests, or as a baseline when benchmarking the framework +/// overhead *without* observer noise. +/// +/// # Example +/// +/// ``` +/// use loopctl::builtin::observer::NoOpObserver; +/// use loopctl::core::AgentObserver; +/// +/// let observer = NoOpObserver; +/// observer.on_session_start(uuid::Uuid::new_v4()); // does nothing +/// observer.on_tool_call("Bash", r#"{"command": "ls"}"#); // does nothing +/// ``` +pub struct NoOpObserver; + +impl AgentObserver for NoOpObserver {} + +/// An observer that logs all lifecycle events via `tracing`. +/// +/// Emits a structured log line for each [`AgentObserver`] callback. The +/// log levels are chosen so that normal operation is visible at `info` +/// level while detailed tool execution is available at `debug` level: +/// +/// | Event | Level | +/// |---------------------------|------------------| +/// | session start / end | `info` | +/// | context compaction | `info` | +/// | turn start / end | `info` / `debug` | +/// | tool call / complete | `debug` | +/// | context warning | `warn` | +/// | fallback triggered | `warn` | +/// | errors / failure reasons | `warn` | +/// +/// Query strings in [`on_turn_start`](AgentObserver::on_turn_start) are +/// previewed to a maximum of 80 characters to avoid flooding logs with +/// long prompts. +/// +/// # Structured Fields +/// +/// Every log line attaches machine-readable fields (`session_id`, `tool`, +/// `duration`, `used_tokens`, etc.) so that log aggregation backends +/// can filter and chart without parsing free-form text. +/// +/// # Thread Safety +/// +/// [`LoggingObserver`] is a zero-sized unit struct — it carries no state +/// and can be freely shared across threads or cloned at zero cost. +/// +/// # Example +/// +/// ``` +/// use loopctl::builtin::observer::LoggingObserver; +/// use loopctl::core::AgentObserver; +/// +/// let observer = LoggingObserver; +/// observer.on_session_start(uuid::Uuid::new_v4()); +/// ``` +pub struct LoggingObserver; + +impl AgentObserver for LoggingObserver { + /// Log session start at `info` level. + /// + /// Emits the `session_id` as a structured field for correlation in + /// log aggregation systems. + fn on_session_start(&self, session_id: uuid::Uuid) { + tracing::info!(%session_id, "Session started"); + } + + /// Log session end at `info` or `warn` level depending on outcome. + /// + /// Successful sessions are logged at `info`; failures and sessions + /// with notes are logged at `warn` with the error message attached. + fn on_session_end(&self, success: bool, error: Option<&str>) { + match (success, error) { + (true, None) => tracing::info!("Session ended successfully"), + (true, Some(e)) => tracing::info!(error = e, "Session ended with note"), + (false, Some(e)) => tracing::warn!(error = e, "Session failed"), + (false, None) => tracing::warn!("Session ended unsuccessfully"), + } + } + + /// Log turn start at `info` level with a truncated query preview. + /// + /// The query is previewed to at most 80 characters to keep log lines + /// readable when dealing with long prompts. + fn on_turn_start(&self, query: &str) { + let preview = if query.len() > 80 { + let end = query + .char_indices() + .take_while(|(i, _)| *i < 80) + .last() + .map_or(0, |(i, c)| i.saturating_add(c.len_utf8())); + &query[..end] + } else { + query + }; + tracing::info!(query = preview, "Turn started"); + } + + /// Log turn completion at `debug` (success) or `warn` (failure) level. + /// + /// Successful turns emit a short `debug` line; failed turns include the + /// error reason at `warn` level for visibility in production alerts. + fn on_turn_end(&self, success: bool, error_reason: Option<&str>) { + if success { + tracing::debug!("Turn completed"); + } else { + tracing::warn!(reason = error_reason.unwrap_or("unknown"), "Turn failed"); + } + } + + /// Log a tool invocation at `debug` level. + /// + /// Emits the tool name as a structured field. The input payload is + /// accepted but not logged to avoid leaking sensitive data (e.g. file + /// contents, API keys). + fn on_tool_call(&self, tool: &str, _input: &str) { + tracing::debug!(tool, "Tool called"); + } + + /// Log tool completion at `debug` (success) or `warn` (failure) level. + /// + /// Includes the tool name, wall-clock `duration`, and — on failure — + /// the error message. The `duration` field is useful for identifying + /// slow tools in production. + fn on_tool_complete( + &self, + tool: &str, + _input: &str, + _output: &str, + duration: Duration, + success: bool, + error: Option<&str>, + ) { + if success { + tracing::debug!(tool, ?duration, "Tool completed"); + } else { + tracing::warn!( + tool, + ?duration, + error = error.unwrap_or("unknown"), + "Tool failed" + ); + } + } + + /// Log a context-window warning at `warn` level. + /// + /// Emits the `used_tokens` and `remaining_tokens` counts so operators + /// can correlate context pressure with agent behaviour. + fn on_context_warning(&self, used_tokens: u64, remaining_tokens: u64) { + tracing::warn!(used_tokens, remaining_tokens, "Context window running low"); + } + + /// Log a context compaction event at `info` level. + /// + /// Emits the message counts before and after so operators can verify + /// that compaction is reducing context size as expected. + fn on_compaction(&self, messages_before: usize, messages_after: usize) { + tracing::info!(messages_before, messages_after, "Context compacted"); + } + + /// Log a model fallback event at `warn` level. + /// + /// Emits both the `from` and `to` model identifiers so operators can + /// detect chronic primary-model failures. + fn on_fallback(&self, from: &str, to: &str) { + tracing::warn!(from, to, "Fallback triggered"); + } +} + +/// A composite observer that fans out every event to multiple inner observers. +/// +/// Holds an ordered list of [`AgentObserver`] trait objects behind `Arc` +/// and forwards each callback to every inner observer in sequence. This +/// implements the classic **Composite** pattern, allowing you to combine +/// logging, metrics, and custom observers without writing a new struct. +/// +/// Observers are called in insertion order. If any individual observer +/// panics, the remaining observers in the list are still called — this +/// is achieved internally via [`std::panic::catch_unwind`] (or the +/// caller's panic handler). This ensures one misbehaving observer does +/// not prevent others from receiving events. +/// +/// # Architecture +/// +/// ```text +/// MultiObserver +/// ┌──────────────┐ +/// on_xxx() ──► │ observers[] │──► obs[0].on_xxx() +/// │ │──► obs[1].on_xxx() +/// │ │──► obs[2].on_xxx() +/// └──────────────┘ +/// ``` +/// +/// # Thread Safety +/// +/// Each inner observer is stored as [`Arc`]``, so the +/// same observer can be shared across multiple [`MultiObserver`] instances +/// or even other parts of the system. The fan-out loop borrows `&self`, +/// meaning all callbacks must be `&self`-safe (no `&mut self`). +/// +/// # Example +/// +/// ``` +/// use loopctl::builtin::observer::{LoggingObserver, MultiObserver, NoOpObserver}; +/// use loopctl::core::AgentObserver; +/// use std::sync::Arc; +/// +/// // Build with owned observers: +/// let multi = MultiObserver::new() +/// .with(LoggingObserver) +/// .with(NoOpObserver); +/// assert_eq!(multi.len(), 2); +/// +/// // Build with pre-Arc'd observers: +/// let shared = Arc::new(LoggingObserver); +/// let multi = MultiObserver::new() +/// .with_arc(shared.clone()) +/// .with_arc(shared); // same observer twice +/// assert_eq!(multi.len(), 2); +/// ``` +pub struct MultiObserver { + /// The ordered list of inner observers to fan out to. + /// + /// Each observer is stored as `Arc` so it can be + /// shared across threads if needed. Observers are called in insertion + /// order — first added is first notified. + /// + /// Starts empty when created via [`new`](MultiObserver::new) or + /// [`default`](MultiObserver::default). Observers are added via + /// [`with`](MultiObserver::with) or [`with_arc`](MultiObserver::with_arc). + observers: Vec>, +} + +// =================================================== +// Construction +// =================================================== + +impl MultiObserver { + /// Create an empty multi-observer with no inner observers. + /// + /// All callbacks will be no-ops until observers are added via + /// [`with`](MultiObserver::with) or [`with_arc`](MultiObserver::with_arc). + /// + /// # Example + /// + /// ``` + /// use loopctl::builtin::observer::MultiObserver; + /// + /// let multi = MultiObserver::new(); + /// assert!(multi.is_empty()); + /// ``` + #[must_use] + pub fn new() -> Self { + Self { + observers: Vec::new(), + } + } + + /// Add an owned observer to the fan-out list. + /// + /// The observer is boxed as `Arc` and appended to + /// the end of the list. Returns `self` for chaining. + /// + /// # Example + /// + /// ``` + /// use loopctl::builtin::observer::{LoggingObserver, MultiObserver, NoOpObserver}; + /// + /// let multi = MultiObserver::new() + /// .with(LoggingObserver) + /// .with(NoOpObserver); + /// assert_eq!(multi.len(), 2); + /// ``` + #[must_use] + pub fn with(mut self, observer: O) -> Self { + self.observers.push(Arc::new(observer)); + self + } + + /// Add an observer that is already behind an `Arc`. + /// + /// Useful when multiple [`MultiObserver`] instances need to share the + /// same inner observer, or when the observer is constructed externally + /// and already wrapped in an `Arc`. Accepts both `Arc` + /// and `Arc`. + /// + /// # Example + /// + /// ``` + /// use loopctl::builtin::observer::{LoggingObserver, MultiObserver}; + /// use std::sync::Arc; + /// + /// // Arc a concrete type: + /// let shared = Arc::new(LoggingObserver); + /// let multi = MultiObserver::new() + /// .with_arc(shared.clone()) + /// .with_arc(shared); + /// assert_eq!(multi.len(), 2); + /// ``` + #[must_use] + pub fn with_arc(mut self, observer: Arc) -> Self { + self.observers.push(observer); + self + } + + /// Number of observers in the fan-out list. + /// + /// Returns the count of inner observers that will receive events. + /// Mirrors the `Vec::len` of the internal observers list. + /// + /// # Example + /// + /// ``` + /// use loopctl::builtin::observer::{LoggingObserver, MultiObserver}; + /// + /// let multi = MultiObserver::new().with(LoggingObserver); + /// assert_eq!(multi.len(), 1); + /// ``` + #[must_use] + pub fn len(&self) -> usize { + self.observers.len() + } + + /// Whether there are no observers in the fan-out list. + /// + /// Returns `true` when [`len`](MultiObserver::len) is zero. When empty, + /// all callbacks are effectively no-ops (the internal loop body never + /// executes). + /// + /// # Example + /// + /// ``` + /// use loopctl::builtin::observer::MultiObserver; + /// + /// let multi = MultiObserver::new(); + /// assert!(multi.is_empty()); + /// ``` + #[must_use] + pub fn is_empty(&self) -> bool { + self.observers.is_empty() + } +} + +impl Default for MultiObserver { + /// Returns an empty multi-observer, equivalent to [`new`](MultiObserver::new). + /// + /// The default instance contains zero inner observers, so all callbacks + /// are effectively no-ops until observers are added. + /// + /// # Example + /// + /// ``` + /// use loopctl::builtin::observer::MultiObserver; + /// + /// let multi = MultiObserver::default(); + /// assert!(multi.is_empty()); + /// ``` + fn default() -> Self { + Self::new() + } +} + +// =================================================== +// AgentObserver implementation +// =================================================== + +impl AgentObserver for MultiObserver { + fn on_session_start(&self, session_id: uuid::Uuid) { + for obs in &self.observers { + if let Err(payload) = catch_unwind(AssertUnwindSafe(|| { + obs.on_session_start(session_id); + })) { + warn!("observer panicked in on_session_start: {payload:?}"); + } + } + } + + fn on_session_end(&self, success: bool, error: Option<&str>) { + for obs in &self.observers { + if let Err(payload) = catch_unwind(AssertUnwindSafe(|| { + obs.on_session_end(success, error); + })) { + warn!("observer panicked in on_session_end: {payload:?}"); + } + } + } + + fn on_turn_start(&self, query: &str) { + for obs in &self.observers { + if let Err(payload) = catch_unwind(AssertUnwindSafe(|| { + obs.on_turn_start(query); + })) { + warn!("observer panicked in on_turn_start: {payload:?}"); + } + } + } + + fn on_turn_end(&self, success: bool, error_reason: Option<&str>) { + for obs in &self.observers { + if let Err(payload) = catch_unwind(AssertUnwindSafe(|| { + obs.on_turn_end(success, error_reason); + })) { + warn!("observer panicked in on_turn_end: {payload:?}"); + } + } + } + + fn on_tool_call(&self, tool: &str, input: &str) { + for obs in &self.observers { + if let Err(payload) = catch_unwind(AssertUnwindSafe(|| { + obs.on_tool_call(tool, input); + })) { + warn!("observer panicked in on_tool_call: {payload:?}"); + } + } + } + + fn on_tool_complete( + &self, + tool: &str, + input: &str, + output: &str, + duration: Duration, + success: bool, + error: Option<&str>, + ) { + for obs in &self.observers { + if let Err(payload) = catch_unwind(AssertUnwindSafe(|| { + obs.on_tool_complete(tool, input, output, duration, success, error); + })) { + warn!("observer panicked in on_tool_complete: {payload:?}"); + } + } + } + + fn on_context_warning(&self, used_tokens: u64, remaining_tokens: u64) { + for obs in &self.observers { + if let Err(payload) = catch_unwind(AssertUnwindSafe(|| { + obs.on_context_warning(used_tokens, remaining_tokens); + })) { + warn!("observer panicked in on_context_warning: {payload:?}"); + } + } + } + + fn on_compaction(&self, messages_before: usize, messages_after: usize) { + for obs in &self.observers { + if let Err(payload) = catch_unwind(AssertUnwindSafe(|| { + obs.on_compaction(messages_before, messages_after); + })) { + warn!("observer panicked in on_compaction: {payload:?}"); + } + } + } + + fn on_fallback(&self, from: &str, to: &str) { + for obs in &self.observers { + if let Err(payload) = catch_unwind(AssertUnwindSafe(|| { + obs.on_fallback(from, to); + })) { + warn!("observer panicked in on_fallback: {payload:?}"); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn noop_observer_does_not_panic() { + let observer = NoOpObserver; + observer.on_session_start(uuid::Uuid::new_v4()); + observer.on_session_end(true, None); + observer.on_turn_start("test query"); + observer.on_turn_end(true, None); + observer.on_tool_call("Read", r#"{"file_path": "test.rs"}"#); + observer.on_tool_complete("Read", "input", "output", Duration::ZERO, true, None); + observer.on_context_warning(1000, 500); + observer.on_compaction(10, 5); + observer.on_fallback("model-a", "model-b"); + } + + #[test] + fn logging_observer_does_not_panic() { + let observer = LoggingObserver; + observer.on_session_start(uuid::Uuid::new_v4()); + observer.on_session_end(true, None); + observer.on_session_end(false, Some("test error")); + observer.on_turn_start("test query"); + observer.on_turn_end(true, None); + observer.on_turn_end(false, Some("some reason")); + observer.on_tool_call("Bash", r#"{"command": "ls"}"#); + observer.on_tool_complete( + "Bash", + "input", + "output", + Duration::from_millis(100), + true, + None, + ); + observer.on_tool_complete( + "Bash", + "input", + "", + Duration::from_millis(50), + false, + Some("command failed"), + ); + observer.on_context_warning(5000, 2000); + observer.on_compaction(20, 10); + observer.on_fallback("primary", "fallback"); + } + + #[test] + fn multi_observer_fans_out() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + let call_count = Arc::new(AtomicUsize::new(0)); + + struct CountingObserver { + count: Arc, + } + + impl AgentObserver for CountingObserver { + fn on_session_start(&self, _session_id: uuid::Uuid) { + self.count.fetch_add(1, Ordering::Relaxed); + } + } + + let multi = MultiObserver::new() + .with(CountingObserver { + count: call_count.clone(), + }) + .with(CountingObserver { + count: call_count.clone(), + }) + .with(CountingObserver { + count: call_count.clone(), + }); + + multi.on_session_start(uuid::Uuid::new_v4()); + assert_eq!(call_count.load(Ordering::Relaxed), 3); + } + + #[test] + fn multi_observer_default_is_empty() { + let multi = MultiObserver::default(); + assert!(multi.is_empty()); + assert_eq!(multi.len(), 0); + } + + #[test] + fn multi_observer_with_arc() { + let multi = MultiObserver::new().with_arc(Arc::new(NoOpObserver)); + assert_eq!(multi.len(), 1); + } +} diff --git a/src/lib.rs b/src/lib.rs index 81dd833..93162fb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,7 @@ //! - **[`api_error`]** — API and infrastructure error types with classification. //! - **[`builder`]** — Fluent builder API for constructing configured agents. //! - **[`core`]** — Foundational traits (`AgentObserver`) and error types. +//! - **[`builtin`]** — Reference implementations of core traits ([`builtin::memory::InMemoryStore`], [`builtin::observer::LoggingObserver`], etc.). //! - **[`loop_control`]** — Detection and intervention modules for agent loops. //! - **[`stream`]** — Streaming event types for LLM API responses. //! - **[`tool`]** — Tool trait, registry, and supporting types. @@ -15,6 +16,7 @@ pub mod api_client; pub mod api_error; pub mod builder; +pub mod builtin; pub mod core; pub mod loop_control; pub mod message; diff --git a/src/loop_control.rs b/src/loop_control.rs index 34e99e8..9ec6977 100644 --- a/src/loop_control.rs +++ b/src/loop_control.rs @@ -1,13 +1,15 @@ //! Loop control — detection and intervention modules for agent loops. //! //! Provides convergence detection, loop detection, fallback management, -//! and a unified detection manager that orchestrates them. +//! a unified detection manager, and a manager bundle for agent infrastructure. //! -//! # Provided Types +//! # Provided Modules //! //! - **[`convergence`]** — Detects when agent responses become semantically similar. //! - **[`loop_detector`]** — Detects repetitive tool-use loops and enforces limits. //! - **[`fallback`]** — Circuit breaker pattern for automatic API model fallback. +//! - **[`detection`]** — Unified manager that orchestrates loop and convergence detection. +//! - **[`bundle`]** — Aggregate struct for the agent's infrastructure managers. pub mod bundle; pub mod convergence;