1use 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
21pub struct ReasoningLoopRunner {
23 pub provider: Arc<dyn InferenceProvider>,
25 pub policy_gate: Arc<dyn ReasoningPolicyGate>,
27 pub executor: Arc<dyn ActionExecutor>,
29 pub context_manager: Arc<dyn ContextManager>,
31 pub circuit_breakers: Arc<CircuitBreakerRegistry>,
33 pub journal: Arc<dyn JournalWriter>,
35 pub knowledge_bridge: Option<Arc<KnowledgeBridge>>,
37}
38
39pub 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 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
75impl<P, E> ReasoningLoopRunnerBuilder<P, E> {
77 pub fn policy_gate(mut self, gate: Arc<dyn ReasoningPolicyGate>) -> Self {
79 self.policy_gate = Some(gate);
80 self
81 }
82
83 pub fn context_manager(mut self, manager: Arc<dyn ContextManager>) -> Self {
85 self.context_manager = Some(manager);
86 self
87 }
88
89 pub fn circuit_breakers(mut self, registry: Arc<CircuitBreakerRegistry>) -> Self {
91 self.circuit_breakers = Some(registry);
92 self
93 }
94
95 pub fn journal(mut self, journal: Arc<dyn JournalWriter>) -> Self {
97 self.journal = Some(journal);
98 self
99 }
100
101 pub fn knowledge_bridge(mut self, bridge: Arc<KnowledgeBridge>) -> Self {
103 self.knowledge_bridge = Some(bridge);
104 self
105 }
106}
107
108impl<E> ReasoningLoopRunnerBuilder<(), E> {
110 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
127impl<P> ReasoningLoopRunnerBuilder<P, ()> {
129 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
146impl ReasoningLoopRunnerBuilder<Arc<dyn InferenceProvider>, Arc<dyn ActionExecutor>> {
148 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 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 let mut config = config;
186 if let Some(ref bridge) = self.knowledge_bridge {
187 config.tool_definitions.extend(bridge.tool_definitions());
188 }
189
190 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 #[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 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 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 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 #[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 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 ¤t_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 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 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 let usage_before = current_loop.state.total_usage.clone();
331
332 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let _runner = ReasoningLoopRunner::builder()
951 .executor(executor)
952 .provider(provider)
953 .build();
954 }
956}