Skip to main content

symbi_runtime/reasoning/
reasoning_loop.rs

1//! Reasoning loop driver
2//!
3//! The main entry point for running an observe-reason-gate-act loop.
4//! This module wires together the typestate phases, context management,
5//! circuit breakers, and journal writing into a single `run()` function.
6
7use std::sync::Arc;
8
9use crate::reasoning::circuit_breaker::CircuitBreakerRegistry;
10use crate::reasoning::context_manager::{ContextManager, DefaultContextManager};
11use crate::reasoning::conversation::Conversation;
12use crate::reasoning::executor::ActionExecutor;
13use crate::reasoning::inference::InferenceProvider;
14use crate::reasoning::knowledge_bridge::KnowledgeBridge;
15use crate::reasoning::knowledge_executor::KnowledgeAwareExecutor;
16use crate::reasoning::loop_types::*;
17use crate::reasoning::phases::{AgentLoop, LoopContinuation, Reasoning};
18use crate::reasoning::policy_bridge::{DefaultPolicyGate, ReasoningPolicyGate};
19use crate::types::AgentId;
20
21/// Configuration bundle for a reasoning loop run.
22pub struct ReasoningLoopRunner {
23    /// Inference provider (cloud or SLM).
24    pub provider: Arc<dyn InferenceProvider>,
25    /// Policy gate (mandatory).
26    pub policy_gate: Arc<dyn ReasoningPolicyGate>,
27    /// Action executor.
28    pub executor: Arc<dyn ActionExecutor>,
29    /// Context manager for token budget enforcement.
30    pub context_manager: Arc<dyn ContextManager>,
31    /// Circuit breaker registry (shared across iterations).
32    pub circuit_breakers: Arc<CircuitBreakerRegistry>,
33    /// Journal writer for durable execution.
34    pub journal: Arc<dyn JournalWriter>,
35    /// Optional knowledge bridge for context-aware reasoning.
36    pub knowledge_bridge: Option<Arc<KnowledgeBridge>>,
37}
38
39/// Builder for `ReasoningLoopRunner` with typestate enforcement.
40///
41/// Only `provider` and `executor` are required. All other fields have
42/// sensible defaults. Call order doesn't matter.
43///
44/// ```ignore
45/// let runner = ReasoningLoopRunner::builder()
46///     .provider(my_provider)
47///     .executor(my_executor)
48///     .build();
49/// ```
50pub struct ReasoningLoopRunnerBuilder<P, E> {
51    provider: P,
52    executor: E,
53    policy_gate: Option<Arc<dyn ReasoningPolicyGate>>,
54    context_manager: Option<Arc<dyn ContextManager>>,
55    circuit_breakers: Option<Arc<CircuitBreakerRegistry>>,
56    journal: Option<Arc<dyn JournalWriter>>,
57    knowledge_bridge: Option<Arc<KnowledgeBridge>>,
58}
59
60impl ReasoningLoopRunner {
61    /// Create a new builder with sensible defaults.
62    pub fn builder() -> ReasoningLoopRunnerBuilder<(), ()> {
63        ReasoningLoopRunnerBuilder {
64            provider: (),
65            executor: (),
66            policy_gate: None,
67            context_manager: None,
68            circuit_breakers: None,
69            journal: None,
70            knowledge_bridge: None,
71        }
72    }
73}
74
75// Methods available regardless of typestate
76impl<P, E> ReasoningLoopRunnerBuilder<P, E> {
77    /// Set a custom policy gate. Default: `DefaultPolicyGate::permissive()`.
78    pub fn policy_gate(mut self, gate: Arc<dyn ReasoningPolicyGate>) -> Self {
79        self.policy_gate = Some(gate);
80        self
81    }
82
83    /// Set a custom context manager. Default: `DefaultContextManager::default()`.
84    pub fn context_manager(mut self, manager: Arc<dyn ContextManager>) -> Self {
85        self.context_manager = Some(manager);
86        self
87    }
88
89    /// Set a custom circuit breaker registry. Default: `CircuitBreakerRegistry::default()`.
90    pub fn circuit_breakers(mut self, registry: Arc<CircuitBreakerRegistry>) -> Self {
91        self.circuit_breakers = Some(registry);
92        self
93    }
94
95    /// Set a custom journal writer. Default: `BufferedJournal::new(1000)`.
96    pub fn journal(mut self, journal: Arc<dyn JournalWriter>) -> Self {
97        self.journal = Some(journal);
98        self
99    }
100
101    /// Set a knowledge bridge. Default: `None`.
102    pub fn knowledge_bridge(mut self, bridge: Arc<KnowledgeBridge>) -> Self {
103        self.knowledge_bridge = Some(bridge);
104        self
105    }
106}
107
108// Set provider (transitions from () to Arc<dyn InferenceProvider>)
109impl<E> ReasoningLoopRunnerBuilder<(), E> {
110    /// Set the inference provider (required).
111    pub fn provider(
112        self,
113        provider: Arc<dyn InferenceProvider>,
114    ) -> ReasoningLoopRunnerBuilder<Arc<dyn InferenceProvider>, E> {
115        ReasoningLoopRunnerBuilder {
116            provider,
117            executor: self.executor,
118            policy_gate: self.policy_gate,
119            context_manager: self.context_manager,
120            circuit_breakers: self.circuit_breakers,
121            journal: self.journal,
122            knowledge_bridge: self.knowledge_bridge,
123        }
124    }
125}
126
127// Set executor (transitions from () to Arc<dyn ActionExecutor>)
128impl<P> ReasoningLoopRunnerBuilder<P, ()> {
129    /// Set the action executor (required).
130    pub fn executor(
131        self,
132        executor: Arc<dyn ActionExecutor>,
133    ) -> ReasoningLoopRunnerBuilder<P, Arc<dyn ActionExecutor>> {
134        ReasoningLoopRunnerBuilder {
135            provider: self.provider,
136            executor,
137            policy_gate: self.policy_gate,
138            context_manager: self.context_manager,
139            circuit_breakers: self.circuit_breakers,
140            journal: self.journal,
141            knowledge_bridge: self.knowledge_bridge,
142        }
143    }
144}
145
146// build() only available when both provider and executor are set
147impl ReasoningLoopRunnerBuilder<Arc<dyn InferenceProvider>, Arc<dyn ActionExecutor>> {
148    /// Build the `ReasoningLoopRunner` with defaults for any unset fields.
149    pub fn build(self) -> ReasoningLoopRunner {
150        ReasoningLoopRunner {
151            provider: self.provider,
152            executor: self.executor,
153            policy_gate: self
154                .policy_gate
155                .unwrap_or_else(|| Arc::new(DefaultPolicyGate::new())),
156            context_manager: self
157                .context_manager
158                .unwrap_or_else(|| Arc::new(DefaultContextManager::default())),
159            circuit_breakers: self
160                .circuit_breakers
161                .unwrap_or_else(|| Arc::new(CircuitBreakerRegistry::default())),
162            journal: self
163                .journal
164                .unwrap_or_else(|| Arc::new(BufferedJournal::new(1000))),
165            knowledge_bridge: self.knowledge_bridge,
166        }
167    }
168}
169
170impl ReasoningLoopRunner {
171    /// Run the full reasoning loop.
172    ///
173    /// This is the main entry point. It creates the initial state, then
174    /// drives the typestate machine through Reasoning → PolicyCheck →
175    /// ToolDispatching → Observing until the loop terminates.
176    pub async fn run(
177        &self,
178        agent_id: AgentId,
179        conversation: Conversation,
180        config: LoopConfig,
181    ) -> LoopResult {
182        let state = LoopState::new(agent_id, conversation);
183
184        // Add knowledge tool definitions if bridge is present
185        let mut config = config;
186        if let Some(ref bridge) = self.knowledge_bridge {
187            config.tool_definitions.extend(bridge.tool_definitions());
188        }
189
190        // Auto-populate tool definitions from executor if config has none
191        if config.tool_definitions.is_empty() {
192            let executor_tools = self.executor.tool_definitions();
193            if !executor_tools.is_empty() {
194                config.tool_definitions = executor_tools;
195            }
196        }
197
198        // Apply tool profile filtering (orga-adaptive: tool curation)
199        #[cfg(feature = "orga-adaptive")]
200        if let Some(ref profile) = config.tool_profile {
201            config.tool_definitions = profile.filter_tools(&config.tool_definitions);
202        }
203
204        // Emit loop started event
205        let start_event = LoopEvent::Started {
206            agent_id: state.agent_id,
207            config: Box::new(config.clone()),
208        };
209        let _ = self
210            .journal
211            .append(JournalEntry {
212                sequence: self.journal.next_sequence().await,
213                timestamp: chrono::Utc::now(),
214                agent_id: state.agent_id,
215                iteration: 0,
216                event: start_event,
217            })
218            .await;
219
220        // Wrap the entire loop in a timeout
221        let timeout = config.timeout;
222        match tokio::time::timeout(timeout, self.run_inner(state, config)).await {
223            Ok(result) => result,
224            Err(_) => {
225                tracing::warn!("Reasoning loop timed out after {:?}", timeout);
226                LoopResult {
227                    output: String::new(),
228                    iterations: 0,
229                    total_usage: crate::reasoning::inference::Usage::default(),
230                    termination_reason: TerminationReason::Timeout,
231                    duration: timeout,
232                    conversation: Conversation::new(),
233                }
234            }
235        }
236    }
237
238    async fn run_inner(&self, state: LoopState, config: LoopConfig) -> LoopResult {
239        let agent_id = state.agent_id;
240        let mut current_loop = AgentLoop::<Reasoning>::new(state, config);
241
242        // Build the effective executor: wrap with KnowledgeAwareExecutor if bridge is present
243        let effective_executor: Arc<dyn ActionExecutor> =
244            if let Some(ref bridge) = self.knowledge_bridge {
245                Arc::new(KnowledgeAwareExecutor::new(
246                    self.executor.clone(),
247                    bridge.clone(),
248                    agent_id,
249                ))
250            } else {
251                self.executor.clone()
252            };
253
254        // Pre-hydration: extract and resolve references from task input (orga-adaptive: cold-start context)
255        #[cfg(feature = "orga-adaptive")]
256        if let Some(ref pre_hydration_config) = current_loop.config.pre_hydration {
257            use crate::reasoning::conversation::ConversationMessage;
258            use crate::reasoning::pre_hydrate::PreHydrationEngine;
259
260            let engine = PreHydrationEngine::new(pre_hydration_config.clone());
261
262            // Extract task input from last user message
263            let task_input = current_loop
264                .state
265                .conversation
266                .messages()
267                .iter()
268                .rev()
269                .find(|m| m.role == crate::reasoning::conversation::MessageRole::User)
270                .map(|m| m.content.clone())
271                .unwrap_or_default();
272
273            if !task_input.is_empty() {
274                let refs = engine.extract_references(&task_input);
275                if !refs.is_empty() {
276                    let hydrated = engine
277                        .hydrate(
278                            &refs,
279                            &self.executor,
280                            &self.circuit_breakers,
281                            &current_loop.config,
282                        )
283                        .await;
284
285                    let references_found = refs.len();
286                    let references_resolved = hydrated.resolved.len();
287                    let references_failed = hydrated.failed.len();
288                    let total_tokens = hydrated.total_tokens;
289
290                    let context_text = PreHydrationEngine::format_context(&hydrated);
291                    if !context_text.is_empty() {
292                        current_loop
293                            .state
294                            .conversation
295                            .push(ConversationMessage::system(context_text));
296                    }
297
298                    // Emit pre-hydration event
299                    let _ = self
300                        .journal
301                        .append(JournalEntry {
302                            sequence: self.journal.next_sequence().await,
303                            timestamp: chrono::Utc::now(),
304                            agent_id,
305                            iteration: 0,
306                            event: LoopEvent::PreHydrationComplete {
307                                references_found,
308                                references_resolved,
309                                references_failed,
310                                total_tokens,
311                            },
312                        })
313                        .await;
314                }
315            }
316        }
317
318        loop {
319            // Inject knowledge context before reasoning if bridge is present
320            if let Some(ref bridge) = self.knowledge_bridge {
321                if let Err(e) = bridge
322                    .inject_context(&agent_id, &mut current_loop.state.conversation)
323                    .await
324                {
325                    tracing::warn!("Knowledge context injection failed: {}", e);
326                }
327            }
328
329            // Snapshot usage before reasoning to compute per-step delta
330            let usage_before = current_loop.state.total_usage.clone();
331
332            // Phase 1: Reasoning
333            let policy_phase = match current_loop
334                .produce_output(self.provider.as_ref(), self.context_manager.as_ref())
335                .await
336            {
337                Ok(phase) => phase,
338                Err(termination) => return termination.into_result(),
339            };
340
341            // Emit ReasoningComplete: captures the raw LLM output BEFORE policy check
342            // so crash recovery can replay from journal without re-calling the LLM
343            let step_usage = crate::reasoning::inference::Usage {
344                prompt_tokens: policy_phase
345                    .state
346                    .total_usage
347                    .prompt_tokens
348                    .saturating_sub(usage_before.prompt_tokens),
349                completion_tokens: policy_phase
350                    .state
351                    .total_usage
352                    .completion_tokens
353                    .saturating_sub(usage_before.completion_tokens),
354                total_tokens: policy_phase
355                    .state
356                    .total_usage
357                    .total_tokens
358                    .saturating_sub(usage_before.total_tokens),
359            };
360            let proposed_actions = policy_phase.proposed_actions();
361            let _ = self
362                .journal
363                .append(JournalEntry {
364                    sequence: self.journal.next_sequence().await,
365                    timestamp: chrono::Utc::now(),
366                    agent_id,
367                    iteration: policy_phase.state.iteration,
368                    event: LoopEvent::ReasoningComplete {
369                        iteration: policy_phase.state.iteration,
370                        actions: proposed_actions,
371                        usage: step_usage,
372                    },
373                })
374                .await;
375
376            // Phase 2: Policy Check
377            let dispatch_phase = match policy_phase.check_policy(self.policy_gate.as_ref()).await {
378                Ok(phase) => phase,
379                Err(termination) => return termination.into_result(),
380            };
381
382            // Emit PolicyEvaluated journal event
383            let (action_count, denied_count) = dispatch_phase.policy_summary();
384            let _ = self
385                .journal
386                .append(JournalEntry {
387                    sequence: self.journal.next_sequence().await,
388                    timestamp: chrono::Utc::now(),
389                    agent_id,
390                    iteration: dispatch_phase.state.iteration,
391                    event: LoopEvent::PolicyEvaluated {
392                        iteration: dispatch_phase.state.iteration,
393                        action_count,
394                        denied_count,
395                    },
396                })
397                .await;
398
399            // Phase 3: Tool Dispatching (uses effective_executor which handles knowledge tools)
400            let dispatch_start = std::time::Instant::now();
401            let observe_phase = match dispatch_phase
402                .dispatch_tools(effective_executor.as_ref(), self.circuit_breakers.as_ref())
403                .await
404            {
405                Ok(phase) => phase,
406                Err(termination) => return termination.into_result(),
407            };
408            let dispatch_duration = dispatch_start.elapsed();
409
410            // Emit ToolsDispatched journal event
411            let observation_count = observe_phase.observation_count();
412            let _ = self
413                .journal
414                .append(JournalEntry {
415                    sequence: self.journal.next_sequence().await,
416                    timestamp: chrono::Utc::now(),
417                    agent_id,
418                    iteration: observe_phase.state.iteration,
419                    event: LoopEvent::ToolsDispatched {
420                        iteration: observe_phase.state.iteration,
421                        tool_count: observation_count,
422                        duration: dispatch_duration,
423                    },
424                })
425                .await;
426
427            // Phase 4: Observation
428            // Emit ObservationsCollected before consuming observe_phase
429            let obs_iteration = observe_phase.state.iteration;
430            let obs_count = observe_phase.observation_count();
431            let _ = self
432                .journal
433                .append(JournalEntry {
434                    sequence: self.journal.next_sequence().await,
435                    timestamp: chrono::Utc::now(),
436                    agent_id,
437                    iteration: obs_iteration,
438                    event: LoopEvent::ObservationsCollected {
439                        iteration: obs_iteration,
440                        observation_count: obs_count,
441                    },
442                })
443                .await;
444
445            match observe_phase.observe_results() {
446                LoopContinuation::Continue(reasoning_loop) => {
447                    current_loop = *reasoning_loop;
448                }
449                LoopContinuation::Complete(result) => {
450                    // Persist learnings if bridge is present and auto_persist is enabled
451                    if let Some(ref bridge) = self.knowledge_bridge {
452                        if let Err(e) = bridge
453                            .persist_learnings(&agent_id, &result.conversation)
454                            .await
455                        {
456                            tracing::warn!("Failed to persist learnings: {}", e);
457                        }
458                    }
459
460                    // Emit termination event
461                    let _ = self.emit_termination_event(agent_id, &result).await;
462                    return result;
463                }
464            }
465        }
466    }
467
468    async fn emit_termination_event(
469        &self,
470        agent_id: AgentId,
471        result: &LoopResult,
472    ) -> Result<(), JournalError> {
473        let event = LoopEvent::Terminated {
474            reason: result.termination_reason.clone(),
475            iterations: result.iterations,
476            total_usage: result.total_usage.clone(),
477            duration: result.duration,
478        };
479        self.journal
480            .append(JournalEntry {
481                sequence: self.journal.next_sequence().await,
482                timestamp: chrono::Utc::now(),
483                agent_id,
484                iteration: result.iterations,
485                event,
486            })
487            .await
488    }
489}
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494    use crate::reasoning::circuit_breaker::CircuitBreakerRegistry;
495    use crate::reasoning::context_manager::DefaultContextManager;
496    use crate::reasoning::conversation::ConversationMessage;
497    use crate::reasoning::executor::DefaultActionExecutor;
498    use crate::reasoning::inference::*;
499    use crate::reasoning::policy_bridge::DefaultPolicyGate;
500
501    /// A mock inference provider for testing the loop.
502    struct MockProvider {
503        responses: std::sync::Mutex<Vec<InferenceResponse>>,
504    }
505
506    impl MockProvider {
507        fn new(responses: Vec<InferenceResponse>) -> Self {
508            Self {
509                responses: std::sync::Mutex::new(responses),
510            }
511        }
512    }
513
514    #[async_trait::async_trait]
515    impl InferenceProvider for MockProvider {
516        async fn complete(
517            &self,
518            _conversation: &Conversation,
519            _options: &InferenceOptions,
520        ) -> Result<InferenceResponse, InferenceError> {
521            let mut responses = self.responses.lock().unwrap();
522            if responses.is_empty() {
523                Ok(InferenceResponse {
524                    content: "I'm done.".into(),
525                    tool_calls: vec![],
526                    finish_reason: FinishReason::Stop,
527                    usage: Usage {
528                        prompt_tokens: 10,
529                        completion_tokens: 5,
530                        total_tokens: 15,
531                    },
532                    model: "mock".into(),
533                })
534            } else {
535                Ok(responses.remove(0))
536            }
537        }
538
539        fn provider_name(&self) -> &str {
540            "mock"
541        }
542        fn default_model(&self) -> &str {
543            "mock-model"
544        }
545        fn supports_native_tools(&self) -> bool {
546            true
547        }
548        fn supports_structured_output(&self) -> bool {
549            true
550        }
551    }
552
553    fn make_runner(provider: Arc<dyn InferenceProvider>) -> ReasoningLoopRunner {
554        ReasoningLoopRunner {
555            provider,
556            policy_gate: Arc::new(DefaultPolicyGate::permissive()),
557            executor: Arc::new(DefaultActionExecutor::default()),
558            context_manager: Arc::new(DefaultContextManager::default()),
559            circuit_breakers: Arc::new(CircuitBreakerRegistry::default()),
560            journal: Arc::new(BufferedJournal::new(1000)),
561            knowledge_bridge: None,
562        }
563    }
564
565    #[tokio::test]
566    async fn test_simple_text_response_terminates() {
567        let provider = Arc::new(MockProvider::new(vec![InferenceResponse {
568            content: "The answer is 42.".into(),
569            tool_calls: vec![],
570            finish_reason: FinishReason::Stop,
571            usage: Usage {
572                prompt_tokens: 20,
573                completion_tokens: 10,
574                total_tokens: 30,
575            },
576            model: "mock".into(),
577        }]));
578
579        let runner = make_runner(provider);
580        let mut conv = Conversation::with_system("You are a test agent.");
581        conv.push(ConversationMessage::user("What is 6 * 7?"));
582
583        let result = runner
584            .run(AgentId::new(), conv, LoopConfig::default())
585            .await;
586
587        assert!(matches!(
588            result.termination_reason,
589            TerminationReason::Completed
590        ));
591        assert_eq!(result.output, "The answer is 42.");
592        assert_eq!(result.iterations, 1);
593        assert_eq!(result.total_usage.total_tokens, 30);
594    }
595
596    #[tokio::test]
597    async fn test_tool_call_then_response() {
598        let provider = Arc::new(MockProvider::new(vec![
599            // First response: tool call
600            InferenceResponse {
601                content: String::new(),
602                tool_calls: vec![ToolCallRequest {
603                    id: "call_1".into(),
604                    name: "search".into(),
605                    arguments: r#"{"q": "weather"}"#.into(),
606                }],
607                finish_reason: FinishReason::ToolCalls,
608                usage: Usage {
609                    prompt_tokens: 20,
610                    completion_tokens: 15,
611                    total_tokens: 35,
612                },
613                model: "mock".into(),
614            },
615            // Second response: final answer
616            InferenceResponse {
617                content: "The weather is sunny.".into(),
618                tool_calls: vec![],
619                finish_reason: FinishReason::Stop,
620                usage: Usage {
621                    prompt_tokens: 40,
622                    completion_tokens: 10,
623                    total_tokens: 50,
624                },
625                model: "mock".into(),
626            },
627        ]));
628
629        let runner = make_runner(provider);
630        let mut conv = Conversation::with_system("You are a weather agent.");
631        conv.push(ConversationMessage::user("What's the weather?"));
632
633        let result = runner
634            .run(AgentId::new(), conv, LoopConfig::default())
635            .await;
636
637        assert!(matches!(
638            result.termination_reason,
639            TerminationReason::Completed
640        ));
641        assert_eq!(result.output, "The weather is sunny.");
642        assert_eq!(result.iterations, 2);
643        assert_eq!(result.total_usage.total_tokens, 85);
644    }
645
646    #[tokio::test]
647    async fn test_max_iterations_termination() {
648        // Provider always returns tool calls → loop should hit max_iterations
649        let tool_response = || InferenceResponse {
650            content: String::new(),
651            tool_calls: vec![ToolCallRequest {
652                id: "call_1".into(),
653                name: "search".into(),
654                arguments: "{}".into(),
655            }],
656            finish_reason: FinishReason::ToolCalls,
657            usage: Usage {
658                prompt_tokens: 10,
659                completion_tokens: 5,
660                total_tokens: 15,
661            },
662            model: "mock".into(),
663        };
664        let provider = Arc::new(MockProvider::new(vec![
665            tool_response(),
666            tool_response(),
667            tool_response(),
668        ]));
669
670        let runner = make_runner(provider);
671        let conv = Conversation::with_system("Infinite loop test");
672
673        let config = LoopConfig {
674            max_iterations: 3,
675            ..Default::default()
676        };
677
678        let result = runner.run(AgentId::new(), conv, config).await;
679        assert!(matches!(
680            result.termination_reason,
681            TerminationReason::MaxIterations
682        ));
683        assert_eq!(result.iterations, 3);
684    }
685
686    #[tokio::test]
687    async fn test_timeout_termination() {
688        // Provider that takes forever
689        struct SlowProvider;
690
691        #[async_trait::async_trait]
692        impl InferenceProvider for SlowProvider {
693            async fn complete(
694                &self,
695                _conv: &Conversation,
696                _opts: &InferenceOptions,
697            ) -> Result<InferenceResponse, InferenceError> {
698                tokio::time::sleep(std::time::Duration::from_secs(10)).await;
699                unreachable!()
700            }
701            fn provider_name(&self) -> &str {
702                "slow"
703            }
704            fn default_model(&self) -> &str {
705                "slow"
706            }
707            fn supports_native_tools(&self) -> bool {
708                false
709            }
710            fn supports_structured_output(&self) -> bool {
711                false
712            }
713        }
714
715        let runner = make_runner(Arc::new(SlowProvider));
716        let conv = Conversation::with_system("Timeout test");
717
718        let config = LoopConfig {
719            timeout: std::time::Duration::from_millis(100),
720            ..Default::default()
721        };
722
723        let result = runner.run(AgentId::new(), conv, config).await;
724        assert!(matches!(
725            result.termination_reason,
726            TerminationReason::Timeout
727        ));
728    }
729
730    #[tokio::test]
731    async fn test_policy_denial_fed_back() {
732        use crate::reasoning::loop_types::LoopDecision;
733
734        /// A gate that denies the first tool call but allows the second
735        struct DenyFirstGate {
736            call_count: std::sync::atomic::AtomicU32,
737        }
738
739        #[async_trait::async_trait]
740        impl ReasoningPolicyGate for DenyFirstGate {
741            async fn evaluate_action(
742                &self,
743                _agent_id: &AgentId,
744                action: &ProposedAction,
745                _state: &LoopState,
746            ) -> LoopDecision {
747                if matches!(action, ProposedAction::ToolCall { .. }) {
748                    let count = self
749                        .call_count
750                        .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
751                    if count == 0 {
752                        return LoopDecision::Deny {
753                            reason: "Not authorized for first call".into(),
754                        };
755                    }
756                }
757                LoopDecision::Allow
758            }
759        }
760
761        let provider = Arc::new(MockProvider::new(vec![
762            // First: tool call (will be denied)
763            InferenceResponse {
764                content: String::new(),
765                tool_calls: vec![ToolCallRequest {
766                    id: "c1".into(),
767                    name: "search".into(),
768                    arguments: "{}".into(),
769                }],
770                finish_reason: FinishReason::ToolCalls,
771                usage: Usage::default(),
772                model: "mock".into(),
773            },
774            // Second: response after denial
775            InferenceResponse {
776                content: "I couldn't use the tool.".into(),
777                tool_calls: vec![],
778                finish_reason: FinishReason::Stop,
779                usage: Usage::default(),
780                model: "mock".into(),
781            },
782        ]));
783
784        let runner = ReasoningLoopRunner {
785            provider,
786            policy_gate: Arc::new(DenyFirstGate {
787                call_count: std::sync::atomic::AtomicU32::new(0),
788            }),
789            executor: Arc::new(DefaultActionExecutor::default()),
790            context_manager: Arc::new(DefaultContextManager::default()),
791            circuit_breakers: Arc::new(CircuitBreakerRegistry::default()),
792            journal: Arc::new(BufferedJournal::new(1000)),
793            knowledge_bridge: None,
794        };
795
796        let conv = Conversation::with_system("test");
797        let result = runner
798            .run(AgentId::new(), conv, LoopConfig::default())
799            .await;
800
801        assert!(matches!(
802            result.termination_reason,
803            TerminationReason::Completed
804        ));
805        assert_eq!(result.output, "I couldn't use the tool.");
806    }
807
808    #[tokio::test]
809    async fn test_runner_auto_populates_tool_definitions_from_executor() {
810        use crate::reasoning::inference::ToolDefinition;
811
812        /// An executor that reports tool definitions.
813        struct ToolfulExecutor;
814
815        #[async_trait::async_trait]
816        impl ActionExecutor for ToolfulExecutor {
817            async fn execute_actions(
818                &self,
819                _actions: &[ProposedAction],
820                _config: &LoopConfig,
821                _circuit_breakers: &CircuitBreakerRegistry,
822            ) -> Vec<Observation> {
823                Vec::new()
824            }
825
826            fn tool_definitions(&self) -> Vec<ToolDefinition> {
827                vec![ToolDefinition {
828                    name: "test_tool".into(),
829                    description: "A test tool".into(),
830                    parameters: serde_json::json!({}),
831                }]
832            }
833        }
834
835        let provider = Arc::new(MockProvider::new(vec![InferenceResponse {
836            content: "Done.".into(),
837            tool_calls: vec![],
838            finish_reason: FinishReason::Stop,
839            usage: Usage {
840                prompt_tokens: 10,
841                completion_tokens: 5,
842                total_tokens: 15,
843            },
844            model: "mock".into(),
845        }]));
846
847        let runner = ReasoningLoopRunner {
848            provider,
849            policy_gate: Arc::new(DefaultPolicyGate::permissive()),
850            executor: Arc::new(ToolfulExecutor),
851            context_manager: Arc::new(DefaultContextManager::default()),
852            circuit_breakers: Arc::new(CircuitBreakerRegistry::default()),
853            journal: Arc::new(BufferedJournal::new(1000)),
854            knowledge_bridge: None,
855        };
856
857        let config = LoopConfig::default();
858        assert!(config.tool_definitions.is_empty());
859
860        let conv = Conversation::with_system("test");
861        let result = runner.run(AgentId::new(), conv, config).await;
862        assert!(matches!(
863            result.termination_reason,
864            TerminationReason::Completed
865        ));
866    }
867
868    #[tokio::test]
869    async fn test_builder_minimal() {
870        let provider: Arc<dyn InferenceProvider> =
871            Arc::new(MockProvider::new(vec![InferenceResponse {
872                content: "Built with builder.".into(),
873                tool_calls: vec![],
874                finish_reason: FinishReason::Stop,
875                usage: Usage {
876                    prompt_tokens: 10,
877                    completion_tokens: 5,
878                    total_tokens: 15,
879                },
880                model: "mock".into(),
881            }]));
882        let executor: Arc<dyn ActionExecutor> = Arc::new(DefaultActionExecutor::default());
883
884        let runner = ReasoningLoopRunner::builder()
885            .provider(provider)
886            .executor(executor)
887            .build();
888
889        let conv = Conversation::with_system("builder test");
890        let result = runner
891            .run(AgentId::new(), conv, LoopConfig::default())
892            .await;
893
894        assert!(matches!(
895            result.termination_reason,
896            TerminationReason::Completed
897        ));
898        assert_eq!(result.output, "Built with builder.");
899    }
900
901    #[tokio::test]
902    async fn test_builder_with_custom_policy_gate() {
903        let provider: Arc<dyn InferenceProvider> = Arc::new(MockProvider::new(vec![
904            InferenceResponse {
905                content: String::new(),
906                tool_calls: vec![ToolCallRequest {
907                    id: "c1".into(),
908                    name: "blocked_tool".into(),
909                    arguments: "{}".into(),
910                }],
911                finish_reason: FinishReason::ToolCalls,
912                usage: Usage::default(),
913                model: "mock".into(),
914            },
915            InferenceResponse {
916                content: "Blocked.".into(),
917                tool_calls: vec![],
918                finish_reason: FinishReason::Stop,
919                usage: Usage::default(),
920                model: "mock".into(),
921            },
922        ]));
923        let executor: Arc<dyn ActionExecutor> = Arc::new(DefaultActionExecutor::default());
924
925        use crate::reasoning::policy_bridge::ToolFilterPolicyGate;
926
927        let runner = ReasoningLoopRunner::builder()
928            .provider(provider)
929            .executor(executor)
930            .policy_gate(Arc::new(ToolFilterPolicyGate::allow(&["allowed_only"])))
931            .build();
932
933        let conv = Conversation::with_system("policy test");
934        let result = runner
935            .run(AgentId::new(), conv, LoopConfig::default())
936            .await;
937
938        assert!(matches!(
939            result.termination_reason,
940            TerminationReason::Completed
941        ));
942    }
943
944    #[test]
945    fn test_builder_order_independent() {
946        let provider: Arc<dyn InferenceProvider> = Arc::new(MockProvider::new(vec![]));
947        let executor: Arc<dyn ActionExecutor> = Arc::new(DefaultActionExecutor::default());
948
949        // executor before provider should also work
950        let _runner = ReasoningLoopRunner::builder()
951            .executor(executor)
952            .provider(provider)
953            .build();
954        // If this compiles and doesn't panic, the test passes
955    }
956}