Skip to main content

xz_agent_core/
engine.rs

1//! Core engine loop implementation.
2//!
3//! Provides the generic [`CoreEngine<Ctx, Out>`](CoreEngine) struct with a
4//! [`run`](CoreEngine::run) method that executes the agent loop,
5//! along with an [`EngineBuilder<Ctx, Out>`](EngineBuilder) for fluent construction.
6//!
7//! # Architecture
8//!
9//! The engine orchestrates the agent data-flow pipeline:
10//!
11//! ```text
12//! Ctx → [ContextBuilder chain] → Thinker → [OutputProcessor chain] → Signal
13//! ```
14//!
15//! Each iteration:
16//! 1. Context flows through a chain of context builders (each can modify it)
17//! 2. The thinker produces output from the context
18//! 3. Output flows through a chain of processors (any can signal Stop)
19//!
20//! # Type Erasure
21//!
22//! Since Rust's native async functions in traits are not object-safe,
23//! this module provides type-erased wrapper types (`DynThinker`, `DynContextBuilder`,
24//! `DynOutputProcessor`) that allow heterogeneous chains of different concrete
25//! types via boxing. Each wrapper struct implements the corresponding trait
26//! by dispatching to an internal erased trait object.
27//!
28//! # Example
29//!
30//! ```rust
31//! use xz_agent_core::engine::{CoreEngine, EngineBuilder};
32//! use xz_agent_core::traits::thinker::Thinker;
33//! use xz_agent_core::traits::context::ContextBuilder;
34//! use xz_agent_core::traits::processor::OutputProcessor;
35//! use xz_agent_core::error::EngineError;
36//! use xz_agent_core::types::signal::Signal;
37//!
38//! // A simple thinker that echoes input.
39//! struct EchoThinker;
40//! impl Thinker for EchoThinker {
41//!     type Context = String;
42//!     type Output = String;
43//!     async fn think(&self, ctx: &Self::Context) -> Result<Self::Output, EngineError> {
44//!         Ok(ctx.clone())
45//!     }
46//! }
47//!
48//! // A processor that stops after the first turn.
49//! struct OneShot;
50//! impl OutputProcessor for OneShot {
51//!     type Context = String;
52//!     type Output = String;
53//!     async fn process(
54//!         &self,
55//!         _output: &Self::Output,
56//!         _ctx: &mut Self::Context,
57//!     ) -> Result<Signal, EngineError> {
58//!         Ok(Signal::Stop)
59//!     }
60//! }
61//!
62//! let engine: CoreEngine<String, String> = EngineBuilder::new()
63//!     .thinker(EchoThinker)
64//!     .processor(OneShot)
65//!     .build()
66//!     .unwrap();
67//! ```
68
69use std::future::Future;
70use std::pin::Pin;
71use std::sync::Arc;
72
73use tokio_util::sync::CancellationToken;
74
75use crate::error::EngineError;
76use crate::traits::context::ContextBuilder;
77use crate::traits::processor::OutputProcessor;
78use crate::traits::thinker::Thinker;
79use crate::types::signal::Signal;
80
81// ─────────────────────────────────────────────────────────────────────────────
82// Type-erased wrapper traits
83// ─────────────────────────────────────────────────────────────────────────────
84
85/// Erased form of [`Thinker`] — boxes the async output.
86///
87/// Generic over the context type `Ctx` and output type `Out` so that
88/// different concrete `Thinker` implementations with the same associated
89/// types can be stored heterogeneously.
90trait ErasedThinker<Ctx, Out>: Send + Sync {
91    /// Type-erased [`Thinker::think`].
92    fn think_erased<'a>(
93        &'a self,
94        ctx: &'a Ctx,
95    ) -> Pin<Box<dyn Future<Output = Result<Out, EngineError>> + Send + 'a>>;
96}
97
98/// Erased form of [`ContextBuilder`] — boxes the async output.
99///
100/// Generic over the context type `Ctx` so that different concrete
101/// `ContextBuilder` implementations can be chained dynamically.
102trait ErasedContextBuilder<Ctx>: Send + Sync {
103    /// Type-erased [`ContextBuilder::build`].
104    fn build_erased<'a>(
105        &'a self,
106        ctx: Ctx,
107    ) -> Pin<Box<dyn Future<Output = Result<Ctx, EngineError>> + Send + 'a>>;
108}
109
110/// Erased form of [`OutputProcessor`] — boxes the async output.
111///
112/// Generic over the context type `Ctx` and output type `Out` so that
113/// different concrete `OutputProcessor` implementations can be chained.
114trait ErasedOutputProcessor<Ctx, Out>: Send + Sync {
115    /// Type-erased [`OutputProcessor::process`].
116    fn process_erased<'a>(
117        &'a self,
118        output: &'a Out,
119        ctx: &'a mut Ctx,
120    ) -> Pin<Box<dyn Future<Output = Result<Signal, EngineError>> + Send + 'a>>;
121}
122
123// ── Blanket implementations ──
124
125impl<T, Ctx, Out> ErasedThinker<Ctx, Out> for T
126where
127    T: Thinker<Context = Ctx, Output = Out> + 'static,
128    Ctx: Send + Sync,
129    Out: Send,
130{
131    fn think_erased<'a>(
132        &'a self,
133        ctx: &'a Ctx,
134    ) -> Pin<Box<dyn Future<Output = Result<Out, EngineError>> + Send + 'a>> {
135        Box::pin(T::think(self, ctx))
136    }
137}
138
139impl<T, Ctx> ErasedContextBuilder<Ctx> for T
140where
141    T: ContextBuilder<Context = Ctx> + 'static,
142    Ctx: Send,
143{
144    fn build_erased<'a>(
145        &'a self,
146        ctx: Ctx,
147    ) -> Pin<Box<dyn Future<Output = Result<Ctx, EngineError>> + Send + 'a>> {
148        Box::pin(T::build(self, ctx))
149    }
150}
151
152impl<T, Ctx, Out> ErasedOutputProcessor<Ctx, Out> for T
153where
154    T: OutputProcessor<Context = Ctx, Output = Out> + 'static,
155    Ctx: Send,
156    Out: Sync,
157{
158    fn process_erased<'a>(
159        &'a self,
160        output: &'a Out,
161        ctx: &'a mut Ctx,
162    ) -> Pin<Box<dyn Future<Output = Result<Signal, EngineError>> + Send + 'a>> {
163        Box::pin(T::process(self, output, ctx))
164    }
165}
166
167// ─────────────────────────────────────────────────────────────────────────────
168// Wrapper structs
169// ─────────────────────────────────────────────────────────────────────────────
170
171/// A concrete wrapper that implements [`Thinker`] via type-erased dispatch.
172///
173/// Allows heterogeneous chains of different concrete `Thinker` types.
174pub struct DynThinker<Ctx, Out>(Arc<dyn ErasedThinker<Ctx, Out>>);
175
176/// A concrete wrapper that implements [`ContextBuilder`] via type-erased dispatch.
177///
178/// Allows heterogeneous chains of different concrete `ContextBuilder` types.
179pub struct DynContextBuilder<Ctx>(Arc<dyn ErasedContextBuilder<Ctx>>);
180
181/// A concrete wrapper that implements [`OutputProcessor`] via type-erased dispatch.
182///
183/// Allows heterogeneous chains of different concrete `OutputProcessor` types.
184pub struct DynOutputProcessor<Ctx, Out>(Arc<dyn ErasedOutputProcessor<Ctx, Out>>);
185
186// ── Constructors ──
187
188impl<Ctx, Out> DynThinker<Ctx, Out> {
189    /// Creates a [`DynThinker`] from any [`Thinker`] implementation.
190    ///
191    /// The thinker must have `Context = Ctx` and `Output = Out`.
192    pub fn new<T>(thinker: T) -> Self
193    where
194        T: Thinker<Context = Ctx, Output = Out> + 'static,
195        Ctx: Send + Sync,
196        Out: Send,
197    {
198        DynThinker(Arc::new(thinker))
199    }
200}
201
202impl<Ctx> DynContextBuilder<Ctx> {
203    /// Creates a [`DynContextBuilder`] from any [`ContextBuilder`] implementation.
204    ///
205    /// The builder must have `Context = Ctx`.
206    pub fn new<T>(builder: T) -> Self
207    where
208        T: ContextBuilder<Context = Ctx> + 'static,
209        Ctx: Send,
210    {
211        DynContextBuilder(Arc::new(builder))
212    }
213}
214
215impl<Ctx, Out> DynOutputProcessor<Ctx, Out> {
216    /// Creates a [`DynOutputProcessor`] from any [`OutputProcessor`] implementation.
217    ///
218    /// The processor must have `Context = Ctx` and `Output = Out`.
219    pub fn new<T>(processor: T) -> Self
220    where
221        T: OutputProcessor<Context = Ctx, Output = Out> + 'static,
222        Ctx: Send,
223        Out: Sync,
224    {
225        DynOutputProcessor(Arc::new(processor))
226    }
227}
228
229// ── Trait implementations for wrappers ──
230
231impl<Ctx, Out> Thinker for DynThinker<Ctx, Out>
232where
233    Ctx: Sync,
234{
235    type Context = Ctx;
236    type Output = Out;
237
238    async fn think(&self, ctx: &Self::Context) -> Result<Self::Output, EngineError> {
239        self.0.think_erased(ctx).await
240    }
241}
242
243impl<Ctx> ContextBuilder for DynContextBuilder<Ctx>
244where
245    Ctx: Send,
246{
247    type Context = Ctx;
248
249    async fn build(&self, ctx: Self::Context) -> Result<Self::Context, EngineError> {
250        self.0.build_erased(ctx).await
251    }
252}
253
254impl<Ctx, Out> OutputProcessor for DynOutputProcessor<Ctx, Out>
255where
256    Ctx: Send,
257    Out: Sync,
258{
259    type Context = Ctx;
260    type Output = Out;
261
262    async fn process(
263        &self,
264        output: &Self::Output,
265        ctx: &mut Self::Context,
266    ) -> Result<Signal, EngineError> {
267        self.0.process_erased(output, ctx).await
268    }
269}
270
271// ─────────────────────────────────────────────────────────────────────────────
272// CoreEngine
273// ─────────────────────────────────────────────────────────────────────────────
274
275/// Result of a single engine turn ([`CoreEngine::run_once`]).
276///
277/// Always carries the thinker's output. [`stopped`](Self::stopped) is `true`
278/// when a processor returned [`Signal::Stop`] (or when no processors are
279/// registered and the single turn is treated as complete).
280#[derive(Debug, Clone)]
281pub struct TurnResult<Out> {
282    /// Output produced by the thinker for this turn.
283    pub output: Out,
284    /// Whether the engine should terminate after this turn.
285    pub stopped: bool,
286}
287
288/// The core agent engine that orchestrates the agent loop.
289///
290/// Generic over the context type `Ctx` and output type `Out`.
291/// Construct using [`EngineBuilder`]. The engine drives the main
292/// think-process cycle with plugin-based extension points for
293/// context building and output processing.
294///
295/// The loop continues until an output processor signals
296/// [`Signal::Stop`](Signal::Stop) or the cancellation token is triggered.
297pub struct CoreEngine<Ctx, Out> {
298    context_builders: Vec<DynContextBuilder<Ctx>>,
299    thinker: DynThinker<Ctx, Out>,
300    output_processors: Vec<DynOutputProcessor<Ctx, Out>>,
301    cancel: CancellationToken,
302}
303
304impl<Ctx, Out> CoreEngine<Ctx, Out>
305where
306    Ctx: Clone + Send + Sync,
307    Out: Send + Sync,
308{
309    /// Runs the agent loop to completion and returns the final context.
310    ///
311    /// Each iteration:
312    /// 1. Checks the cancellation token
313    /// 2. Runs the context builder chain (each may modify the context)
314    /// 3. Calls the thinker to produce output
315    /// 4. Runs the output processor chain (first Stop wins)
316    ///
317    /// The loop continues until a processor returns [`Signal::Stop`] or
318    /// cancellation is triggered.
319    ///
320    /// If **no** output processors are registered, the loop executes exactly
321    /// one turn and returns. This prevents an infinite loop when the engine
322    /// has nothing that can signal stop.
323    ///
324    /// # Parameters
325    ///
326    /// * `initial` — The starting context for the first iteration.
327    ///
328    /// # Returns
329    ///
330    /// The final context after the last processor chain (or after a single
331    /// turn when no processors are configured). Processors may have mutated
332    /// the context (for example, appended messages).
333    ///
334    /// # Errors
335    ///
336    /// Returns [`EngineError::Cancelled`] if the cancellation token is
337    /// triggered. Propagates errors from thinkers, context builders,
338    /// and output processors.
339    pub async fn run(&self, initial: Ctx) -> Result<Ctx, EngineError> {
340        let (ctx, _last) = self.run_with_output(initial).await?;
341        Ok(ctx)
342    }
343
344    /// Runs the agent loop to completion, returning the final context and
345    /// the **last** thinker output (if any turn completed).
346    ///
347    /// Same loop as [`run`](Self::run), but also yields the output from the
348    /// final turn so outer paradigms (e.g. Ralph) can verify it without
349    /// losing the result when processors signal [`Signal::Stop`].
350    ///
351    /// # Returns
352    ///
353    /// `(final_ctx, Some(last_output))` after at least one successful think.
354    /// `(final_ctx, None)` only if the loop exits before thinking (should not
355    /// occur under normal processor configurations).
356    pub async fn run_with_output(&self, initial: Ctx) -> Result<(Ctx, Option<Out>), EngineError> {
357        let mut ctx = initial;
358        let mut last_output: Option<Out> = None;
359
360        loop {
361            if self.cancel.is_cancelled() {
362                return Err(EngineError::Cancelled);
363            }
364
365            for builder in &self.context_builders {
366                ctx = builder.build(ctx).await?;
367            }
368
369            let output = self.thinker.think(&ctx).await?;
370
371            if self.output_processors.is_empty() {
372                last_output = Some(output);
373                break;
374            }
375
376            let mut stop = false;
377            for processor in &self.output_processors {
378                match processor.process(&output, &mut ctx).await? {
379                    Signal::Stop => {
380                        stop = true;
381                        break;
382                    }
383                    Signal::Continue => {}
384                }
385            }
386
387            last_output = Some(output);
388
389            if stop {
390                break;
391            }
392        }
393
394        Ok((ctx, last_output))
395    }
396
397    /// Runs a single turn of the engine loop.
398    ///
399    /// Executes one complete iteration:
400    ///
401    /// 1. Checks the cancellation token
402    /// 2. Runs the context builder chain on the mutable context
403    /// 3. Calls the thinker to produce output
404    /// 4. Runs the output processor chain (shared `&output`, first Stop wins)
405    ///
406    /// Always returns the thinker output. [`TurnResult::stopped`] is `true`
407    /// when a processor signalled [`Signal::Stop`] (including a normal
408    /// terminal text turn from tool orchestration).
409    ///
410    /// # Errors
411    ///
412    /// Returns [`EngineError::Cancelled`] if the cancellation token is
413    /// triggered. Propagates errors from thinkers, context builders,
414    /// and output processors.
415    pub async fn run_once(&self, ctx: &mut Ctx) -> Result<TurnResult<Out>, EngineError> {
416        if self.cancel.is_cancelled() {
417            return Err(EngineError::Cancelled);
418        }
419
420        for builder in &self.context_builders {
421            let next = builder.build(ctx.clone()).await?;
422            *ctx = next;
423        }
424
425        let output = self.thinker.think(ctx).await?;
426
427        // No processors → single-turn completion (stopped = true).
428        let mut stopped = self.output_processors.is_empty();
429        for processor in &self.output_processors {
430            match processor.process(&output, ctx).await? {
431                Signal::Stop => {
432                    stopped = true;
433                    break;
434                }
435                Signal::Continue => {}
436            }
437        }
438
439        Ok(TurnResult { output, stopped })
440    }
441
442    /// Returns a cloneable handle to the engine's cancellation token.
443    ///
444    /// External code can clone this token and call `.cancel()` to
445    /// trigger graceful shutdown of the engine loop at the start
446    /// of the next iteration.
447    pub fn cancel_handle(&self) -> CancellationToken {
448        self.cancel.clone()
449    }
450}
451
452// ─────────────────────────────────────────────────────────────────────────────
453// EngineBuilder
454// ─────────────────────────────────────────────────────────────────────────────
455
456/// Fluent builder for constructing a [`CoreEngine`].
457///
458/// # Example
459///
460/// ```rust
461/// use xz_agent_core::engine::{CoreEngine, EngineBuilder};
462/// use xz_agent_core::traits::thinker::Thinker;
463/// use xz_agent_core::error::EngineError;
464///
465/// struct MyThinker;
466/// impl Thinker for MyThinker {
467///     type Context = String;
468///     type Output = String;
469///     async fn think(&self, ctx: &Self::Context) -> Result<Self::Output, EngineError> {
470///         Ok(format!("processed: {}", ctx))
471///     }
472/// }
473///
474/// let engine: CoreEngine<String, String> = EngineBuilder::new()
475///     .thinker(MyThinker)
476///     .build()
477///     .unwrap();
478/// ```
479pub struct EngineBuilder<Ctx, Out> {
480    thinker: Option<DynThinker<Ctx, Out>>,
481    context_builders: Vec<DynContextBuilder<Ctx>>,
482    output_processors: Vec<DynOutputProcessor<Ctx, Out>>,
483    cancel: CancellationToken,
484}
485
486impl<Ctx, Out> Default for EngineBuilder<Ctx, Out> {
487    fn default() -> Self {
488        Self::new()
489    }
490}
491
492impl<Ctx, Out> EngineBuilder<Ctx, Out> {
493    /// Creates a new builder with default settings.
494    ///
495    /// A thinker must be provided via [`thinker`](Self::thinker)
496    /// before calling [`build`](Self::build).
497    pub fn new() -> Self {
498        EngineBuilder {
499            thinker: None,
500            context_builders: Vec::new(),
501            output_processors: Vec::new(),
502            cancel: CancellationToken::new(),
503        }
504    }
505}
506
507impl<Ctx, Out> EngineBuilder<Ctx, Out>
508where
509    Ctx: Send + Sync,
510    Out: Send + Sync,
511{
512    /// Sets the thinker used to produce output from context.
513    ///
514    /// The thinker is the only required component. All other chains
515    /// are optional.
516    pub fn thinker(mut self, thinker: impl Thinker<Context = Ctx, Output = Out> + 'static) -> Self {
517        self.thinker = Some(DynThinker::new(thinker));
518        self
519    }
520
521    /// Appends a context builder to the builder chain.
522    ///
523    /// Builders are applied in the order they are added. Each builder
524    /// receives the context produced by the previous one and may
525    /// modify it.
526    pub fn context(mut self, builder: impl ContextBuilder<Context = Ctx> + 'static) -> Self {
527        self.context_builders.push(DynContextBuilder::new(builder));
528        self
529    }
530
531    /// Appends an output processor to the processor chain.
532    ///
533    /// Processors are consulted in the order they are added. The first
534    /// processor to return [`Signal::Stop`] terminates the loop.
535    pub fn processor(
536        mut self,
537        processor: impl OutputProcessor<Context = Ctx, Output = Out> + 'static,
538    ) -> Self {
539        self.output_processors.push(DynOutputProcessor::new(processor));
540        self
541    }
542
543    /// Sets the cancellation token for the engine.
544    ///
545    /// When the token is cancelled, the engine loop exits at the start
546    /// of the next turn with [`EngineError::Cancelled`].
547    pub fn cancel(mut self, token: CancellationToken) -> Self {
548        self.cancel = token;
549        self
550    }
551
552    /// Builds the [`CoreEngine`].
553    ///
554    /// # Errors
555    ///
556    /// Returns [`EngineError::Config`] if no thinker has been set. The
557    /// thinker is the only required component; all other chains are optional.
558    pub fn build(self) -> Result<CoreEngine<Ctx, Out>, EngineError> {
559        let thinker = self
560            .thinker
561            .ok_or_else(|| EngineError::Config("thinker is required".into()))?;
562        Ok(CoreEngine {
563            context_builders: self.context_builders,
564            thinker,
565            output_processors: self.output_processors,
566            cancel: self.cancel,
567        })
568    }
569}
570
571// ─────────────────────────────────────────────────────────────────────────────
572// Tests
573// ─────────────────────────────────────────────────────────────────────────────
574
575#[cfg(test)]
576mod tests {
577    use super::*;
578    use std::sync::Arc as StdArc;
579    use std::sync::atomic::{AtomicUsize, Ordering};
580
581    // ── Test mocks ──
582
583    /// A thinker that echoes the context as output.
584    struct EchoThinker;
585    impl Thinker for EchoThinker {
586        type Context = String;
587        type Output = String;
588        async fn think(&self, ctx: &Self::Context) -> Result<Self::Output, EngineError> {
589            Ok(ctx.clone())
590        }
591    }
592
593    /// A thinker that returns a fixed output (ignoring context).
594    struct FixedThinker {
595        output: String,
596    }
597    impl Thinker for FixedThinker {
598        type Context = String;
599        type Output = String;
600        async fn think(&self, _ctx: &Self::Context) -> Result<Self::Output, EngineError> {
601            Ok(self.output.clone())
602        }
603    }
604
605    /// A context builder that appends a suffix.
606    struct AppendSuffix {
607        suffix: String,
608    }
609    impl ContextBuilder for AppendSuffix {
610        type Context = String;
611        async fn build(&self, mut ctx: Self::Context) -> Result<Self::Context, EngineError> {
612            ctx.push_str(&self.suffix);
613            Ok(ctx)
614        }
615    }
616
617    /// A counting context builder — records how many times it was called.
618    struct CountingBuilder {
619        count: StdArc<AtomicUsize>,
620    }
621    impl CountingBuilder {
622        fn new(count: StdArc<AtomicUsize>) -> Self {
623            CountingBuilder { count }
624        }
625    }
626    impl ContextBuilder for CountingBuilder {
627        type Context = String;
628        async fn build(&self, ctx: Self::Context) -> Result<Self::Context, EngineError> {
629            self.count.fetch_add(1, Ordering::SeqCst);
630            Ok(ctx)
631        }
632    }
633
634    /// An output processor that always returns Continue.
635    struct ContinueProcessor;
636    impl OutputProcessor for ContinueProcessor {
637        type Context = String;
638        type Output = String;
639        async fn process(
640            &self,
641            _output: &Self::Output,
642            _ctx: &mut Self::Context,
643        ) -> Result<Signal, EngineError> {
644            Ok(Signal::Continue)
645        }
646    }
647
648    /// An output processor that always returns Stop.
649    struct StopProcessor;
650    impl OutputProcessor for StopProcessor {
651        type Context = String;
652        type Output = String;
653        async fn process(
654            &self,
655            _output: &Self::Output,
656            _ctx: &mut Self::Context,
657        ) -> Result<Signal, EngineError> {
658            Ok(Signal::Stop)
659        }
660    }
661
662    /// An output processor that modifies the context (appends output).
663    struct AppendOutput;
664    impl OutputProcessor for AppendOutput {
665        type Context = String;
666        type Output = String;
667        async fn process(
668            &self,
669            output: &Self::Output,
670            ctx: &mut Self::Context,
671        ) -> Result<Signal, EngineError> {
672            ctx.push_str(output);
673            Ok(Signal::Continue)
674        }
675    }
676
677    /// An output processor that stops when the output contains "stop".
678    struct StopOnMatch {
679        keyword: &'static str,
680    }
681    impl OutputProcessor for StopOnMatch {
682        type Context = String;
683        type Output = String;
684        async fn process(
685            &self,
686            output: &Self::Output,
687            _ctx: &mut Self::Context,
688        ) -> Result<Signal, EngineError> {
689            if output.contains(self.keyword) { Ok(Signal::Stop) } else { Ok(Signal::Continue) }
690        }
691    }
692
693    /// A thinker that echoes the context and counts invocations.
694    struct CountingEchoThinker {
695        count: StdArc<AtomicUsize>,
696    }
697    impl Thinker for CountingEchoThinker {
698        type Context = String;
699        type Output = String;
700        async fn think(&self, ctx: &Self::Context) -> Result<Self::Output, EngineError> {
701            self.count.fetch_add(1, Ordering::SeqCst);
702            Ok(ctx.clone())
703        }
704    }
705
706    /// A context builder that records its identity in the context and counts calls.
707    struct RecordingBuilder {
708        id: &'static str,
709        count: StdArc<AtomicUsize>,
710    }
711    impl ContextBuilder for RecordingBuilder {
712        type Context = String;
713        async fn build(&self, mut ctx: Self::Context) -> Result<Self::Context, EngineError> {
714            self.count.fetch_add(1, Ordering::SeqCst);
715            ctx.push_str(&format!("|{}", self.id));
716            Ok(ctx)
717        }
718    }
719
720    /// An output processor that counts calls and returns a configurable signal.
721    struct CountingSignalProcessor {
722        count: StdArc<AtomicUsize>,
723        signal: Signal,
724    }
725    impl OutputProcessor for CountingSignalProcessor {
726        type Context = String;
727        type Output = String;
728        async fn process(
729            &self,
730            _output: &Self::Output,
731            _ctx: &mut Self::Context,
732        ) -> Result<Signal, EngineError> {
733            self.count.fetch_add(1, Ordering::SeqCst);
734            Ok(self.signal)
735        }
736    }
737
738    // ── Tests ──
739
740    #[test]
741    fn test_builder_missing_thinker_returns_err() {
742        let result: Result<CoreEngine<String, String>, _> = EngineBuilder::new().build();
743        assert!(result.is_err(), "build without thinker should fail");
744    }
745
746    #[test]
747    fn test_builder_with_thinker_returns_ok() {
748        let result = EngineBuilder::new().thinker(EchoThinker).build();
749        assert!(result.is_ok());
750    }
751
752    #[tokio::test]
753    async fn test_minimal_engine_single_iteration() {
754        // Engine with a thinker that echoes and a Stop processor.
755        // Should run exactly one iteration and succeed.
756        let engine: CoreEngine<String, String> =
757            EngineBuilder::new().thinker(EchoThinker).processor(StopProcessor).build().unwrap();
758
759        let result = engine.run("hello".into()).await;
760        assert!(result.is_ok());
761    }
762
763    #[tokio::test]
764    async fn test_context_builder_chain() {
765        let count = StdArc::new(AtomicUsize::new(0));
766
767        let engine: CoreEngine<String, String> = EngineBuilder::new()
768            .context(AppendSuffix { suffix: " world".into() })
769            .context(CountingBuilder::new(StdArc::clone(&count)))
770            .context(AppendSuffix { suffix: "!".into() })
771            .thinker(EchoThinker)
772            .processor(StopProcessor)
773            .build()
774            .unwrap();
775
776        let result = engine.run("hello".into()).await;
777        assert!(result.is_ok());
778
779        // Verify the counting builder was called exactly once
780        assert_eq!(count.load(Ordering::SeqCst), 1);
781    }
782
783    #[tokio::test]
784    async fn test_context_builder_modifies_context() {
785        // Verify that context builders actually modify the context.
786        // The thinker echoes the (modified) context.
787        // We use a processor that appends output to context and runs
788        // exactly once. After the run, we can't easily inspect the
789        // final context — but we can verify the pipeline completes.
790        let engine: CoreEngine<String, String> = EngineBuilder::new()
791            .context(AppendSuffix { suffix: " world".into() })
792            .thinker(EchoThinker)
793            .processor(StopProcessor)
794            .build()
795            .unwrap();
796
797        let result = engine.run("hello".into()).await;
798        assert!(result.is_ok());
799    }
800
801    #[tokio::test]
802    async fn test_processor_continues_loop() {
803        // With ContinueProcessor, the engine would loop forever without
804        // a stop mechanism. Use a StopAfterN processor approach instead:
805        // a counting context builder that tracks iterations and a
806        // processor that stops after N iterations.
807        let iteration_count = StdArc::new(AtomicUsize::new(0));
808        let count_clone = StdArc::clone(&iteration_count);
809
810        struct StopAfterN {
811            count: StdArc<AtomicUsize>,
812            limit: usize,
813        }
814        impl OutputProcessor for StopAfterN {
815            type Context = String;
816            type Output = String;
817            async fn process(
818                &self,
819                _output: &Self::Output,
820                _ctx: &mut Self::Context,
821            ) -> Result<Signal, EngineError> {
822                let current = self.count.fetch_add(1, Ordering::SeqCst) + 1;
823                if current >= self.limit { Ok(Signal::Stop) } else { Ok(Signal::Continue) }
824            }
825        }
826
827        let engine: CoreEngine<String, String> = EngineBuilder::new()
828            .thinker(EchoThinker)
829            .processor(StopAfterN { count: StdArc::clone(&count_clone), limit: 3 })
830            .build()
831            .unwrap();
832
833        let result = engine.run("hello".into()).await;
834        assert!(result.is_ok());
835
836        // Should have run exactly 3 iterations
837        assert_eq!(count_clone.load(Ordering::SeqCst), 3);
838    }
839
840    #[tokio::test]
841    async fn test_stop_signal_breaks_loop() {
842        let engine: CoreEngine<String, String> =
843            EngineBuilder::new().thinker(EchoThinker).processor(StopProcessor).build().unwrap();
844
845        let result = engine.run("test".into()).await;
846        assert!(result.is_ok());
847    }
848
849    #[tokio::test]
850    async fn test_cancellation_returns_cancelled_error() {
851        let token = CancellationToken::new();
852        token.cancel(); // Pre-cancel before engine starts
853
854        let engine: CoreEngine<String, String> =
855            EngineBuilder::new().thinker(EchoThinker).cancel(token).build().unwrap();
856
857        let result = engine.run("test".into()).await;
858
859        assert!(result.is_err());
860        match result.unwrap_err() {
861            EngineError::Cancelled => {} // expected
862            other => panic!("expected Cancelled, got {other:?}"),
863        }
864    }
865
866    #[tokio::test]
867    async fn test_cancel_handle() {
868        let engine: CoreEngine<String, String> =
869            EngineBuilder::new().thinker(EchoThinker).processor(ContinueProcessor).build().unwrap();
870
871        let handle = engine.cancel_handle();
872        assert!(!handle.is_cancelled());
873
874        handle.cancel();
875        assert!(handle.is_cancelled());
876
877        let result = engine.run("test".into()).await;
878        assert!(matches!(result.unwrap_err(), EngineError::Cancelled));
879    }
880
881    #[tokio::test]
882    async fn test_processor_chain_order() {
883        // Processors are consulted in order. The first Stop wins.
884        // With [ContinueProcessor, StopProcessor], the first returns
885        // Continue, the second returns Stop → loop stops after 1 iteration.
886        let engine: CoreEngine<String, String> = EngineBuilder::new()
887            .thinker(EchoThinker)
888            .processor(ContinueProcessor)
889            .processor(StopProcessor)
890            .build()
891            .unwrap();
892
893        let result = engine.run("test".into()).await;
894        assert!(result.is_ok());
895    }
896
897    #[tokio::test]
898    async fn test_processor_modifies_context() {
899        // AppendOutput processor appends the thinker output to the context.
900        // After the first iteration, context becomes "hellohello".
901        // The StopOnMatch processor stops when output contains a keyword.
902        // Since output = context (EchoThinker), on 2nd iter context is "hellohello",
903        // thinker echoes it → output "hellohello", doesn't contain "stop" →
904        // StopOnMatch won't stop... this loops forever.
905        //
906        // Better test: use a processor that stops after first iteration
907        // combined with a processor that modifies context.
908        let count = StdArc::new(AtomicUsize::new(0));
909        let count2 = StdArc::clone(&count);
910
911        struct StopAfterOne {
912            count: StdArc<AtomicUsize>,
913        }
914        impl OutputProcessor for StopAfterOne {
915            type Context = String;
916            type Output = String;
917            async fn process(
918                &self,
919                _output: &Self::Output,
920                _ctx: &mut Self::Context,
921            ) -> Result<Signal, EngineError> {
922                let n = self.count.fetch_add(1, Ordering::SeqCst) + 1;
923                if n >= 2 { Ok(Signal::Stop) } else { Ok(Signal::Continue) }
924            }
925        }
926
927        let engine: CoreEngine<String, String> = EngineBuilder::new()
928            .thinker(EchoThinker)
929            .processor(AppendOutput)
930            .processor(StopAfterOne { count: StdArc::clone(&count2) })
931            .build()
932            .unwrap();
933
934        // Run with "hello" — first iter: echo → "hello", append → ctx="hellohello",
935        // StopAfterOne count=1 → Continue. Second iter: echo → "hellohello",
936        // append → ctx="hellohellohellohello", StopAfterOne count=2 → Stop.
937        let result = engine.run("hello".into()).await;
938        assert!(result.is_ok());
939        assert_eq!(count2.load(Ordering::SeqCst), 2);
940        // Final context must be returned to the caller.
941        assert_eq!(result.unwrap(), "hellohellohellohello");
942    }
943
944    #[tokio::test]
945    async fn test_multiple_context_builders() {
946        let engine: CoreEngine<String, String> = EngineBuilder::new()
947            .context(AppendSuffix { suffix: " world".into() })
948            .context(AppendSuffix { suffix: "!".into() })
949            .thinker(FixedThinker { output: "done".into() })
950            .processor(StopOnMatch { keyword: "done" })
951            .build()
952            .unwrap();
953
954        let result = engine.run("hello".into()).await;
955        assert!(result.is_ok());
956    }
957
958    #[tokio::test]
959    async fn test_dyn_context_builder_clone() {
960        // Verify DynContextBuilder can be constructed from any ContextBuilder
961        let builder = DynContextBuilder::<String>::new(AppendSuffix { suffix: " test".into() });
962        let ctx: String = builder.build("hello".into()).await.unwrap();
963        assert_eq!(ctx, "hello test");
964    }
965
966    #[tokio::test]
967    async fn test_dyn_thinker_clone() {
968        let thinker = DynThinker::<String, String>::new(EchoThinker);
969        let result = thinker.think(&"input".to_string()).await.unwrap();
970        assert_eq!(result, "input");
971    }
972
973    #[tokio::test]
974    async fn test_dyn_output_processor_clone() {
975        let processor = DynOutputProcessor::<String, String>::new(StopProcessor);
976        let mut ctx = String::from("test");
977        let result = processor.process(&"output".into(), &mut ctx).await.unwrap();
978        assert_eq!(result, Signal::Stop);
979    }
980
981    // ── Integration tests ──
982
983    #[tokio::test]
984    async fn test_complete_loop_with_mock() {
985        // Verify all three components (ContextBuilder, Thinker, OutputProcessor)
986        // are invoked during a complete engine loop.
987        let cb_count = StdArc::new(AtomicUsize::new(0));
988        let t_count = StdArc::new(AtomicUsize::new(0));
989        let op_count = StdArc::new(AtomicUsize::new(0));
990
991        let engine: CoreEngine<String, String> = EngineBuilder::new()
992            .context(CountingBuilder::new(StdArc::clone(&cb_count)))
993            .thinker(CountingEchoThinker { count: StdArc::clone(&t_count) })
994            .processor(CountingSignalProcessor {
995                count: StdArc::clone(&op_count),
996                signal: Signal::Stop,
997            })
998            .build()
999            .unwrap();
1000
1001        let result = engine.run("hello".into()).await;
1002        assert!(result.is_ok());
1003
1004        assert_eq!(cb_count.load(Ordering::SeqCst), 1);
1005        assert_eq!(t_count.load(Ordering::SeqCst), 1);
1006        assert_eq!(op_count.load(Ordering::SeqCst), 1);
1007    }
1008
1009    #[tokio::test]
1010    async fn test_three_context_builders_executed() {
1011        // Register 3 ContextBuilders and verify they all execute.
1012        let count_a = StdArc::new(AtomicUsize::new(0));
1013        let count_b = StdArc::new(AtomicUsize::new(0));
1014        let count_c = StdArc::new(AtomicUsize::new(0));
1015
1016        let engine: CoreEngine<String, String> = EngineBuilder::new()
1017            .context(RecordingBuilder { id: "A", count: StdArc::clone(&count_a) })
1018            .context(RecordingBuilder { id: "B", count: StdArc::clone(&count_b) })
1019            .context(RecordingBuilder { id: "C", count: StdArc::clone(&count_c) })
1020            .thinker(FixedThinker { output: "done".into() })
1021            .processor(StopOnMatch { keyword: "done" })
1022            .build()
1023            .unwrap();
1024
1025        let result = engine.run("init".into()).await;
1026        assert!(result.is_ok());
1027
1028        assert_eq!(count_a.load(Ordering::SeqCst), 1);
1029        assert_eq!(count_b.load(Ordering::SeqCst), 1);
1030        assert_eq!(count_c.load(Ordering::SeqCst), 1);
1031    }
1032
1033    #[tokio::test]
1034    async fn test_output_processor_stop_signal() {
1035        // Stop signal exits the loop after exactly one iteration.
1036        let t_count = StdArc::new(AtomicUsize::new(0));
1037        let op_count = StdArc::new(AtomicUsize::new(0));
1038
1039        let engine: CoreEngine<String, String> = EngineBuilder::new()
1040            .thinker(CountingEchoThinker { count: StdArc::clone(&t_count) })
1041            .processor(CountingSignalProcessor {
1042                count: StdArc::clone(&op_count),
1043                signal: Signal::Stop,
1044            })
1045            .build()
1046            .unwrap();
1047
1048        let result = engine.run("test".into()).await;
1049        assert!(result.is_ok());
1050
1051        assert_eq!(t_count.load(Ordering::SeqCst), 1);
1052        assert_eq!(op_count.load(Ordering::SeqCst), 1);
1053    }
1054
1055    #[tokio::test]
1056    async fn test_continue_signal_continues() {
1057        // Continue signal does not stop the loop; chain proceeds to next
1058        // processor (Stop) which terminates the loop.
1059        let continue_count = StdArc::new(AtomicUsize::new(0));
1060        let stop_count = StdArc::new(AtomicUsize::new(0));
1061
1062        let engine: CoreEngine<String, String> = EngineBuilder::new()
1063            .thinker(EchoThinker)
1064            .processor(CountingSignalProcessor {
1065                count: StdArc::clone(&continue_count),
1066                signal: Signal::Continue,
1067            })
1068            .processor(CountingSignalProcessor {
1069                count: StdArc::clone(&stop_count),
1070                signal: Signal::Stop,
1071            })
1072            .build()
1073            .unwrap();
1074
1075        let result = engine.run("test".into()).await;
1076        assert!(result.is_ok());
1077
1078        // Both processors ran: Continue let the chain proceed to Stop.
1079        assert_eq!(continue_count.load(Ordering::SeqCst), 1);
1080        assert_eq!(stop_count.load(Ordering::SeqCst), 1);
1081    }
1082
1083    #[tokio::test]
1084    async fn test_empty_context_and_no_processors() {
1085        // Engine with no context builders and no output processors can be
1086        // built and enters the run loop. With a pre-cancelled token it
1087        // returns Cancelled immediately, proving it runs.
1088        let token = CancellationToken::new();
1089        token.cancel();
1090
1091        let engine: CoreEngine<String, String> =
1092            EngineBuilder::new().thinker(EchoThinker).cancel(token).build().unwrap();
1093
1094        let result = engine.run("data".into()).await;
1095        assert!(
1096            matches!(result, Err(EngineError::Cancelled)),
1097            "expected Cancelled, got {result:?}"
1098        );
1099    }
1100
1101    #[tokio::test]
1102    async fn test_run_returns_final_context() {
1103        let engine: CoreEngine<String, String> = EngineBuilder::new()
1104            .context(AppendSuffix { suffix: " world".into() })
1105            .thinker(EchoThinker)
1106            .processor(AppendOutput)
1107            .processor(StopProcessor)
1108            .build()
1109            .unwrap();
1110
1111        // initial "hi" → context "hi world" → think echoes → append → "hi worldhi world"
1112        let final_ctx = engine.run("hi".into()).await.unwrap();
1113        assert_eq!(final_ctx, "hi worldhi world");
1114    }
1115
1116    #[tokio::test]
1117    async fn test_no_processors_stops_after_one_turn() {
1118        let t_count = StdArc::new(AtomicUsize::new(0));
1119        let engine: CoreEngine<String, String> = EngineBuilder::new()
1120            .thinker(CountingEchoThinker { count: StdArc::clone(&t_count) })
1121            .build()
1122            .unwrap();
1123
1124        let final_ctx = engine.run("once".into()).await.unwrap();
1125        assert_eq!(final_ctx, "once");
1126        assert_eq!(t_count.load(Ordering::SeqCst), 1);
1127    }
1128
1129    #[tokio::test]
1130    async fn test_iterator_counting() {
1131        // Verify the engine runs exactly N iterations before stopping.
1132        let iter_count = StdArc::new(AtomicUsize::new(0));
1133        let count_clone = StdArc::clone(&iter_count);
1134
1135        struct StopAfterN {
1136            count: StdArc<AtomicUsize>,
1137            limit: usize,
1138        }
1139        impl OutputProcessor for StopAfterN {
1140            type Context = String;
1141            type Output = String;
1142            async fn process(
1143                &self,
1144                _output: &Self::Output,
1145                _ctx: &mut Self::Context,
1146            ) -> Result<Signal, EngineError> {
1147                let current = self.count.fetch_add(1, Ordering::SeqCst) + 1;
1148                if current >= self.limit { Ok(Signal::Stop) } else { Ok(Signal::Continue) }
1149            }
1150        }
1151
1152        let engine: CoreEngine<String, String> = EngineBuilder::new()
1153            .thinker(EchoThinker)
1154            .processor(StopAfterN { count: StdArc::clone(&count_clone), limit: 5 })
1155            .build()
1156            .unwrap();
1157
1158        let result = engine.run("start".into()).await;
1159        assert!(result.is_ok());
1160        assert_eq!(count_clone.load(Ordering::SeqCst), 5);
1161    }
1162}