|
| 1 | +//! Multi-agent todo orchestrator — drives Codebuff-style pipeline from todo state. |
| 2 | +use super::*; |
| 3 | +use anyhow::Result; |
| 4 | +use jcode_task_types::TodoItem; |
| 5 | + |
| 6 | +/// Classify a todo into an agent type (planner, file-picker, editor, code-reviewer, basher). |
| 7 | +pub(super) fn classify_todo(todo: &TodoItem) -> String { |
| 8 | + let content = todo.content.to_ascii_lowercase(); |
| 9 | + let group = todo.group.as_deref().unwrap_or("").to_ascii_lowercase(); |
| 10 | + if group.contains("plan") || group.contains("foundation") { return "planner".into(); } |
| 11 | + if group.contains("test") || group.contains("verify") || group.contains("qa") { return "basher".into(); } |
| 12 | + if group.contains("review") { return "code-reviewer".into(); } |
| 13 | + if group.contains("search") || group.contains("find") { return "file-picker".into(); } |
| 14 | + if content.contains("plan") || content.contains("analyz") || content.contains("design") { return "planner".into(); } |
| 15 | + if content.contains("test") || content.contains("verif") || content.contains("check") { return "basher".into(); } |
| 16 | + if content.contains("review") || content.contains("audit") { return "code-reviewer".into(); } |
| 17 | + if content.contains("search") || content.contains("find") || content.starts_with("read") { return "file-picker".into(); } |
| 18 | + "editor".into() |
| 19 | +} |
| 20 | + |
| 21 | +/// Build the prompt for a sub-agent based on its type and the todo. |
| 22 | +fn build_prompt(todo: &TodoItem) -> String { |
| 23 | + match classify_todo(todo).as_str() { |
| 24 | + "planner" => format!("Analyze this task and produce a step-by-step plan:\n\n{}", todo.content), |
| 25 | + "file-picker" => format!("Find relevant files in the codebase for this task:\n\n{}", todo.content), |
| 26 | + "editor" => format!("Task: {}\nGroup: {}\nPriority: {}", todo.content, todo.group.as_deref().unwrap_or("default"), if todo.priority.is_empty() { "medium" } else { &todo.priority }), |
| 27 | + "code-reviewer" => format!("Review the code changes for this task:\n\n{}", todo.content), |
| 28 | + "basher" => format!("Run relevant tests for this task:\n\n{}", todo.content), |
| 29 | + _ => todo.content.clone(), |
| 30 | + } |
| 31 | +} |
| 32 | + |
| 33 | +/// Allowed-tool set matching each agent's `.toml` definition. |
| 34 | +pub(crate) fn build_allowed_tools(agent_type: &str) -> HashSet<String> { |
| 35 | + let tools: Vec<&str> = match agent_type { |
| 36 | + "planner" => vec!["read","glob","grep","codesearch","session_search","ls"], |
| 37 | + "file-picker" => vec!["ls","glob","read"], |
| 38 | + "editor" => vec!["read","write","edit","hashline_edit","propose_edit","glob","grep","codesearch","ls","bash"], |
| 39 | + "code-reviewer" => vec!["read","glob","grep","codesearch","ls"], |
| 40 | + "basher" => vec!["bash","read","glob","ls"], |
| 41 | + _ => vec!["read","bash"], |
| 42 | + }; |
| 43 | + tools.into_iter().map(String::from).collect() |
| 44 | +} |
| 45 | + |
| 46 | +impl Agent { |
| 47 | + /// Enable/disable the todo orchestrator (post-turn sub-agent pipeline). |
| 48 | + pub fn set_todo_orchestrator_enabled(&mut self, enabled: bool) { self.todo_orchestrator_enabled = enabled; } |
| 49 | + pub fn todo_orchestrator_enabled(&self) -> bool { self.todo_orchestrator_enabled } |
| 50 | + |
| 51 | + /// Run the todo pipeline: spawn sub-agents for all incomplete todos. |
| 52 | + pub async fn poll_todo_pipeline(&mut self) -> Result<usize> { |
| 53 | + let session_id = self.session.id.clone(); |
| 54 | + let todos = crate::todo::load_todos(&session_id).unwrap_or_default(); |
| 55 | + let incomplete: Vec<TodoItem> = todos.into_iter().filter(|t| !matches!(t.status.as_str(), "completed" | "cancelled")).collect(); |
| 56 | + if incomplete.is_empty() { return Ok(0); } |
| 57 | + |
| 58 | + let provider = Arc::clone(&self.provider); |
| 59 | + let registry = self.registry.clone(); |
| 60 | + let mut processed = 0usize; |
| 61 | + |
| 62 | + for todo in &incomplete { |
| 63 | + let child_session = Session::create(Some(self.session.id.clone()), Some(format!("orchestrator-{}", todo.id))); |
| 64 | + let mut child = Agent::new_with_session(provider.clone(), registry.clone(), child_session, Some(build_allowed_tools(&classify_todo(todo)))); |
| 65 | + match child.run_once_capture_inner(&build_prompt(todo)).await { |
| 66 | + Ok(output) => { crate::logging::info(&format!("[orchestrator] '{}' done ({} chars)", classify_todo(&todo), output.len())); processed += 1; } |
| 67 | + Err(e) => { crate::logging::warn(&format!("[orchestrator] '{}' failed: {e}", classify_todo(&todo))); } |
| 68 | + } |
| 69 | + } |
| 70 | + if processed > 0 { crate::logging::info(&format!("[orchestrator] processed {processed} todos")); } |
| 71 | + Ok(processed) |
| 72 | + } |
| 73 | +} |
| 74 | + |
| 75 | +#[cfg(test)] |
| 76 | +mod tests { |
| 77 | + use super::*; |
| 78 | + fn td(c: &str, g: Option<&str>) -> TodoItem { TodoItem { content: c.into(), group: g.map(String::from), ..Default::default() } } |
| 79 | + fn check(c: &str, g: Option<&str>, expected: &str) { assert_eq!(classify_todo(&td(c, g)), expected); } |
| 80 | + #[test] fn t_planner() { check("Design the auth", None, "planner"); } |
| 81 | + #[test] fn t_editor() { check("Implement button", None, "editor"); } |
| 82 | + #[test] fn t_basher() { check("Fix test", Some("qa"), "basher"); } |
| 83 | + #[test] fn t_reviewer() { check("Review PR", None, "code-reviewer"); } |
| 84 | + #[test] fn t_filepicker() { check("Find files", Some("search"), "file-picker"); } |
| 85 | + #[test] fn t_tools_readonly() { let t = build_allowed_tools("planner"); assert!(t.contains("read")); assert!(!t.contains("write")); } |
| 86 | + #[test] fn t_tools_editor() { let t = build_allowed_tools("editor"); assert!(t.contains("write")); assert!(t.contains("bash")); } |
| 87 | +} |
0 commit comments