bamboo_agent/agent/loop_module/
mod.rs1pub mod config;
23pub mod runner;
24pub mod stream;
25pub mod todo_context;
26pub mod todo_evaluation;
27
28pub use config::AgentLoopConfig;
29pub use runner::{run_agent_loop, run_agent_loop_with_config};
30pub use todo_context::TodoLoopContext;
31pub use todo_evaluation::{evaluate_todo_progress, TodoEvaluationResult};
32
33#[cfg(test)]
34mod tests {
35 use std::sync::Arc;
36
37 use crate::agent::core::composition::CompositionExecutor;
38 use crate::agent::core::tools::{
39 execute_tool_call, handle_tool_result_with_agentic_support, AgenticToolResult,
40 FunctionCall, ToolCall, ToolExecutor, ToolHandlingOutcome, ToolRegistry, ToolResult,
41 };
42 use crate::agent::core::{AgentEvent, Session};
43 use crate::agent::tools::BuiltinToolExecutor;
44 use tokio::sync::mpsc;
45
46 use crate::agent::loop_module::config::AgentLoopConfig;
47
48 fn make_tool_call(id: &str, name: &str, arguments: &str) -> ToolCall {
49 ToolCall {
50 id: id.to_string(),
51 tool_type: "function".to_string(),
52 function: FunctionCall {
53 name: name.to_string(),
54 arguments: arguments.to_string(),
55 },
56 }
57 }
58
59 #[test]
60 fn agent_loop_config_default() {
61 let config = AgentLoopConfig::default();
62 assert_eq!(config.max_rounds, 50);
63 assert!(config.system_prompt.is_none());
64 assert!(config.additional_tool_schemas.is_empty());
65 assert!(config.tool_registry.is_empty());
66 assert!(config.composition_executor.is_none());
67 assert!(config.skill_manager.is_none());
68 assert!(!config.skip_initial_user_message);
69 }
70
71 #[test]
72 fn skip_initial_message_flag() {
73 let config = AgentLoopConfig {
74 skip_initial_user_message: true,
75 ..Default::default()
76 };
77 assert!(config.skip_initial_user_message);
78 }
79
80 #[tokio::test]
81 async fn need_clarification_sends_event() {
82 let (event_tx, mut event_rx) = mpsc::channel(8);
83 let tools: Arc<dyn ToolExecutor> = Arc::new(BuiltinToolExecutor::new());
84 let mut session = Session::new("s1", "test-model");
85 let tool_call = make_tool_call("call_parent", "smart_tool", "{}");
86 let result = ToolResult {
87 success: true,
88 result: serde_json::to_string(&AgenticToolResult::NeedClarification {
89 question: "Which file should I inspect?".to_string(),
90 options: Some(vec!["src/main.rs".to_string(), "src/lib.rs".to_string()]),
91 })
92 .unwrap(),
93 display_preference: None,
94 };
95
96 let outcome = handle_tool_result_with_agentic_support(
97 &result,
98 &tool_call,
99 &event_tx,
100 &mut session,
101 tools.as_ref(),
102 None,
103 )
104 .await;
105
106 assert_eq!(outcome, ToolHandlingOutcome::AwaitingClarification);
107
108 let event = event_rx.recv().await.expect("missing clarification event");
109 match event {
110 AgentEvent::NeedClarification { question, options } => {
111 assert_eq!(question, "Which file should I inspect?");
112 assert_eq!(
113 options,
114 Some(vec!["src/main.rs".to_string(), "src/lib.rs".to_string()])
115 );
116 }
117 other => panic!("unexpected event: {other:?}"),
118 }
119 }
120
121 #[tokio::test]
122 async fn need_more_actions_executes_sub_actions() {
123 let (event_tx, mut event_rx) = mpsc::channel(16);
124 let tools: Arc<dyn ToolExecutor> = Arc::new(BuiltinToolExecutor::new());
125 let mut session = Session::new("s2", "test-model");
126 let sub_action = make_tool_call("call_sub", "get_current_dir", "{}");
127 let parent_call = make_tool_call("call_parent", "smart_tool", "{}");
128 let result = ToolResult {
129 success: true,
130 result: serde_json::to_string(&AgenticToolResult::NeedMoreActions {
131 actions: vec![sub_action.clone()],
132 reason: "Need workspace context".to_string(),
133 })
134 .unwrap(),
135 display_preference: None,
136 };
137
138 let outcome = handle_tool_result_with_agentic_support(
139 &result,
140 &parent_call,
141 &event_tx,
142 &mut session,
143 tools.as_ref(),
144 None,
145 )
146 .await;
147
148 assert_eq!(outcome, ToolHandlingOutcome::Continue);
149 assert!(session
150 .messages
151 .iter()
152 .any(
153 |message| message.tool_call_id.as_deref() == Some("call_sub")
154 && !message.content.is_empty()
155 ));
156
157 let mut saw_sub_start = false;
158 let mut saw_sub_complete = false;
159
160 while let Ok(event) = event_rx.try_recv() {
161 match event {
162 AgentEvent::ToolStart { tool_call_id, .. } if tool_call_id == "call_sub" => {
163 saw_sub_start = true;
164 }
165 AgentEvent::ToolComplete { tool_call_id, .. } if tool_call_id == "call_sub" => {
166 saw_sub_complete = true;
167 }
168 _ => {}
169 }
170 }
171
172 assert!(saw_sub_start);
173 assert!(saw_sub_complete);
174 }
175
176 #[tokio::test]
177 async fn execute_tool_call_falls_back_when_composition_misses_tool() {
178 let tools: Arc<dyn ToolExecutor> = Arc::new(BuiltinToolExecutor::new());
179 let composition_executor =
180 Arc::new(CompositionExecutor::new(Arc::new(ToolRegistry::new())));
181 let tool_call = make_tool_call("call_sub", "get_current_dir", "{}");
182
183 let result = execute_tool_call(&tool_call, tools.as_ref(), Some(composition_executor))
184 .await
185 .expect("fallback execution should succeed");
186
187 assert!(result.success);
188 assert!(!result.result.is_empty());
189 }
190}