bamboo_agent/agent/loop_module/
todo_context.rs1use crate::agent::core::todo::{TodoItem, TodoItemStatus, TodoList};
7use crate::agent::core::tools::ToolResult;
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone)]
16pub struct TodoLoopContext {
17 pub session_id: String,
19
20 pub items: Vec<TodoLoopItem>,
22
23 pub active_item_id: Option<String>,
25
26 pub current_round: u32,
28
29 pub max_rounds: u32,
31
32 pub created_at: DateTime<Utc>,
34
35 pub updated_at: DateTime<Utc>,
37
38 pub version: u64,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct TodoLoopItem {
45 pub id: String,
47
48 pub description: String,
50
51 pub status: TodoItemStatus,
53
54 pub tool_calls: Vec<ToolCallRecord>,
56
57 pub started_at_round: Option<u32>,
59
60 pub completed_at_round: Option<u32>,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct ToolCallRecord {
67 pub round: u32,
69
70 pub tool_name: String,
72
73 pub success: bool,
75
76 pub timestamp: DateTime<Utc>,
78}
79
80impl TodoLoopContext {
81 pub fn from_session(session: &crate::agent::core::Session) -> Option<Self> {
83 session.todo_list.as_ref().map(|todo_list| {
84 let existing_version = session
87 .metadata
88 .get("todo_list_version")
89 .and_then(|v| v.parse::<u64>().ok())
90 .unwrap_or(0);
91
92 Self {
93 session_id: todo_list.session_id.clone(),
94 items: todo_list
95 .items
96 .iter()
97 .map(|item| TodoLoopItem {
98 id: item.id.clone(),
99 description: item.description.clone(),
100 status: item.status.clone(),
101 tool_calls: Vec::new(),
102 started_at_round: None,
103 completed_at_round: None,
104 })
105 .collect(),
106 active_item_id: None,
107 current_round: 0,
108 max_rounds: 50,
109 created_at: todo_list.created_at,
110 updated_at: todo_list.updated_at,
111 version: existing_version,
112 }
113 })
114 }
115
116 pub fn track_tool_execution(&mut self, tool_name: &str, result: &ToolResult, round: u32) {
120 self.current_round = round;
121
122 let record = ToolCallRecord {
124 round,
125 tool_name: tool_name.to_string(),
126 success: result.success,
127 timestamp: Utc::now(),
128 };
129
130 if let Some(ref active_id) = self.active_item_id {
132 if let Some(item) = self.items.iter_mut().find(|i| &i.id == active_id) {
133 item.tool_calls.push(record);
134 self.updated_at = Utc::now();
135 self.version += 1;
136 }
137 }
138 }
139
140 pub fn set_active_item(&mut self, item_id: &str) {
144 if let Some(ref prev_id) = self.active_item_id {
146 if let Some(item) = self.items.iter_mut().find(|i| &i.id == prev_id) {
147 item.status = TodoItemStatus::Completed;
148 item.completed_at_round = Some(self.current_round);
149 }
150 }
151
152 self.active_item_id = Some(item_id.to_string());
154 if let Some(item) = self.items.iter_mut().find(|i| i.id == item_id) {
155 item.status = TodoItemStatus::InProgress;
156 item.started_at_round = Some(self.current_round);
157 }
158
159 self.updated_at = Utc::now();
160 self.version += 1;
161 }
162
163 pub fn update_item_status(&mut self, item_id: &str, status: TodoItemStatus) {
165 if let Some(item) = self.items.iter_mut().find(|i| i.id == item_id) {
166 item.status = status.clone();
167
168 match &status {
169 TodoItemStatus::InProgress => {
170 item.started_at_round = Some(self.current_round);
171 self.active_item_id = Some(item_id.to_string());
172 }
173 TodoItemStatus::Completed => {
174 item.completed_at_round = Some(self.current_round);
175 if self.active_item_id.as_deref() == Some(item_id) {
176 self.active_item_id = None;
177 }
178 }
179 _ => {}
180 }
181
182 self.updated_at = Utc::now();
183 self.version += 1;
184 }
185 }
186
187 pub fn is_all_completed(&self) -> bool {
189 !self.items.is_empty()
190 && self
191 .items
192 .iter()
193 .all(|item| matches!(item.status, TodoItemStatus::Completed))
194 }
195
196 pub fn format_for_prompt(&self) -> String {
198 if self.items.is_empty() {
199 return String::new();
200 }
201
202 let mut output = format!(
203 "\n\n## Current Task List (Round {}/{})\n",
204 self.current_round + 1,
205 self.max_rounds
206 );
207
208 for item in &self.items {
209 let status_icon = match item.status {
210 TodoItemStatus::Pending => "[ ]",
211 TodoItemStatus::InProgress => "[/]",
212 TodoItemStatus::Completed => "[x]",
213 TodoItemStatus::Blocked => "[!]",
214 };
215
216 output.push_str(&format!(
217 "\n{} {}: {}",
218 status_icon, item.id, item.description
219 ));
220
221 if !item.tool_calls.is_empty() {
222 output.push_str(&format!(" ({} tool calls)", item.tool_calls.len()));
223 }
224 }
225
226 let completed = self
227 .items
228 .iter()
229 .filter(|i| matches!(i.status, TodoItemStatus::Completed))
230 .count();
231 output.push_str(&format!(
232 "\n\nProgress: {}/{} tasks completed",
233 completed,
234 self.items.len()
235 ));
236
237 output
238 }
239
240 pub fn into_todo_list(self) -> TodoList {
242 TodoList {
243 session_id: self.session_id,
244 title: "Agent Tasks".to_string(),
245 items: self
246 .items
247 .into_iter()
248 .map(|loop_item| TodoItem {
249 id: loop_item.id,
250 description: loop_item.description,
251 status: loop_item.status,
252 depends_on: Vec::new(),
253 notes: String::new(),
254 })
255 .collect(),
256 created_at: self.created_at,
257 updated_at: self.updated_at,
258 }
259 }
260
261 pub fn auto_match_tool_to_item(&mut self, tool_name: &str) {
263 if self.active_item_id.is_some() {
264 return; }
266
267 let tool_lower = tool_name.to_lowercase();
269 let matching_item_id = self.items.iter().find(|item| {
270 let desc_lower = item.description.to_lowercase();
271 desc_lower.contains(&tool_lower) ||
273 (tool_lower.contains("file") && desc_lower.contains("file")) ||
275 (tool_lower.contains("command") && (desc_lower.contains("run") || desc_lower.contains("execute")))
276 }).map(|item| item.id.clone());
277
278 if let Some(item_id) = matching_item_id {
279 self.set_active_item(&item_id);
280 }
281 }
282
283 pub fn auto_update_status(&mut self, tool_name: &str, result: &ToolResult) {
285 if self.active_item_id.is_none() {
287 self.auto_match_tool_to_item(tool_name);
288 }
289
290 if let Some(ref active_id) = self.active_item_id.clone() {
291 let action = self
293 .items
294 .iter()
295 .find(|i| &i.id == active_id)
296 .and_then(|item| {
297 if result.success {
298 if self.should_mark_completed(item) {
299 Some(TodoItemStatus::Completed)
300 } else {
301 None
302 }
303 } else if self.should_mark_blocked(item) {
304 Some(TodoItemStatus::Blocked)
305 } else {
306 None
307 }
308 });
309
310 if let Some(new_status) = action {
312 if let Some(item) = self.items.iter_mut().find(|i| &i.id == active_id) {
313 item.status = new_status.clone();
314 if matches!(new_status, TodoItemStatus::Completed) {
315 item.completed_at_round = Some(self.current_round);
316 self.active_item_id = None;
317 }
318 self.version += 1;
319 self.updated_at = Utc::now(); }
321 }
322 }
323 }
324
325 fn should_mark_completed(&self, item: &TodoLoopItem) -> bool {
327 let success_count = item.tool_calls.iter().filter(|r| r.success).count();
329 success_count >= 3
330 }
331
332 fn should_mark_blocked(&self, item: &TodoLoopItem) -> bool {
334 let recent_failures = item
336 .tool_calls
337 .iter()
338 .rev()
339 .take(2)
340 .filter(|r| !r.success)
341 .count();
342 recent_failures >= 2
343 }
344}
345
346#[cfg(test)]
347mod tests {
348 use super::*;
349
350 fn create_test_todo_list() -> crate::agent::core::Session {
351 let mut session = crate::agent::core::Session::new("test-session", "test-model");
352 let todo_list = TodoList {
353 session_id: "test-session".to_string(),
354 title: "Test Tasks".to_string(),
355 items: vec![
356 TodoItem {
357 id: "task-1".to_string(),
358 description: "Read configuration file".to_string(),
359 status: TodoItemStatus::Pending,
360 depends_on: Vec::new(),
361 notes: String::new(),
362 },
363 TodoItem {
364 id: "task-2".to_string(),
365 description: "Run tests".to_string(),
366 status: TodoItemStatus::Pending,
367 depends_on: Vec::new(),
368 notes: String::new(),
369 },
370 ],
371 created_at: Utc::now(),
372 updated_at: Utc::now(),
373 };
374 session.set_todo_list(todo_list);
375 session
376 }
377
378 #[test]
379 fn test_from_session() {
380 let session = create_test_todo_list();
381 let ctx = TodoLoopContext::from_session(&session).unwrap();
382
383 assert_eq!(ctx.session_id, "test-session");
384 assert_eq!(ctx.items.len(), 2);
385 assert_eq!(ctx.items[0].id, "task-1");
386 assert_eq!(ctx.items[0].tool_calls.len(), 0);
387 }
388
389 #[test]
390 fn test_track_tool_execution() {
391 let session = create_test_todo_list();
392 let mut ctx = TodoLoopContext::from_session(&session).unwrap();
393
394 ctx.set_active_item("task-1");
396
397 let result = ToolResult {
399 success: true,
400 result: "OK".to_string(),
401 display_preference: None,
402 };
403 ctx.track_tool_execution("read_file", &result, 1);
404
405 assert_eq!(ctx.items[0].tool_calls.len(), 1);
406 assert_eq!(ctx.items[0].tool_calls[0].tool_name, "read_file");
407 assert!(ctx.items[0].tool_calls[0].success);
408 assert_eq!(ctx.version, 2); }
410
411 #[test]
412 fn test_set_active_item() {
413 let session = create_test_todo_list();
414 let mut ctx = TodoLoopContext::from_session(&session).unwrap();
415
416 ctx.set_active_item("task-1");
417
418 assert_eq!(ctx.active_item_id, Some("task-1".to_string()));
419 assert_eq!(ctx.items[0].status, TodoItemStatus::InProgress);
420 assert_eq!(ctx.items[0].started_at_round, Some(0));
421 }
422
423 #[test]
424 fn test_is_all_completed() {
425 let session = create_test_todo_list();
426 let mut ctx = TodoLoopContext::from_session(&session).unwrap();
427
428 assert!(!ctx.is_all_completed());
429
430 ctx.items[0].status = TodoItemStatus::Completed;
431 ctx.items[1].status = TodoItemStatus::Completed;
432
433 assert!(ctx.is_all_completed());
434 }
435
436 #[test]
437 fn test_format_for_prompt() {
438 let session = create_test_todo_list();
439 let mut ctx = TodoLoopContext::from_session(&session).unwrap();
440 ctx.current_round = 2;
441 ctx.max_rounds = 10;
442
443 let prompt = ctx.format_for_prompt();
444
445 assert!(prompt.contains("Round 3/10"));
446 assert!(prompt.contains("task-1"));
447 assert!(prompt.contains("task-2"));
448 }
449
450 #[test]
451 fn test_auto_match_tool_to_item() {
452 let session = create_test_todo_list();
453 let mut ctx = TodoLoopContext::from_session(&session).unwrap();
454
455 ctx.auto_match_tool_to_item("read_file");
457
458 assert_eq!(ctx.active_item_id, Some("task-1".to_string()));
460 }
461
462 #[test]
463 fn test_auto_update_status_completed() {
464 let session = create_test_todo_list();
465 let mut ctx = TodoLoopContext::from_session(&session).unwrap();
466
467 ctx.set_active_item("task-1");
468 ctx.current_round = 1;
469
470 let result = ToolResult {
472 success: true,
473 result: "OK".to_string(),
474 display_preference: None,
475 };
476
477 ctx.track_tool_execution("read_file", &result, 1);
478 ctx.auto_update_status("read_file", &result);
479 assert_eq!(ctx.items[0].status, TodoItemStatus::InProgress);
480
481 ctx.track_tool_execution("read_file", &result, 2);
482 ctx.auto_update_status("read_file", &result);
483 assert_eq!(ctx.items[0].status, TodoItemStatus::InProgress);
484
485 ctx.track_tool_execution("read_file", &result, 3);
486 ctx.auto_update_status("read_file", &result);
487
488 assert_eq!(ctx.items[0].status, TodoItemStatus::Completed);
490 assert_eq!(ctx.active_item_id, None);
491 }
492
493 #[test]
494 fn test_auto_update_status_blocked() {
495 let session = create_test_todo_list();
496 let mut ctx = TodoLoopContext::from_session(&session).unwrap();
497
498 ctx.set_active_item("task-1");
499 ctx.current_round = 1;
500
501 let fail_result = ToolResult {
503 success: false,
504 result: "Error".to_string(),
505 display_preference: None,
506 };
507
508 ctx.track_tool_execution("read_file", &fail_result, 1);
509 ctx.auto_update_status("read_file", &fail_result);
510 assert_eq!(ctx.items[0].status, TodoItemStatus::InProgress);
511
512 ctx.track_tool_execution("read_file", &fail_result, 2);
513 ctx.auto_update_status("read_file", &fail_result);
514
515 assert_eq!(ctx.items[0].status, TodoItemStatus::Blocked);
517 }
518
519 #[test]
520 fn test_into_todo_list() {
521 let session = create_test_todo_list();
522 let ctx = TodoLoopContext::from_session(&session).unwrap();
523
524 let todo_list = ctx.into_todo_list();
525
526 assert_eq!(todo_list.session_id, "test-session");
527 assert_eq!(todo_list.items.len(), 2);
528 }
529}