Skip to main content

argentor_builtins/
agent_delegate.rs

1use argentor_core::{ArgentorResult, ToolCall, ToolResult};
2use argentor_skills::skill::{Skill, SkillDescriptor};
3use async_trait::async_trait;
4use std::sync::Arc;
5
6/// Abstraction over the orchestrator's task queue.
7/// Implemented by the orchestrator crate to avoid circular dependencies.
8#[async_trait]
9pub trait TaskQueueHandle: Send + Sync {
10    /// Add a new task and return its ID.
11    async fn add_task(
12        &self,
13        description: String,
14        role: String,
15        dependencies: Vec<String>,
16    ) -> ArgentorResult<String>;
17
18    /// Get status info for a specific task.
19    async fn get_task_info(&self, task_id: &str) -> ArgentorResult<Option<TaskInfo>>;
20
21    /// List all tasks with summary info.
22    async fn list_tasks(&self) -> ArgentorResult<Vec<TaskInfo>>;
23
24    /// Get aggregate counts.
25    async fn task_summary(&self) -> ArgentorResult<TaskSummary>;
26}
27
28/// Summary info about a task.
29#[derive(Debug, Clone, serde::Serialize)]
30pub struct TaskInfo {
31    /// Unique task identifier.
32    pub id: String,
33    /// Human-readable task description.
34    pub description: String,
35    /// Agent role assigned to this task.
36    pub role: String,
37    /// Current status (e.g., "pending", "running", "completed").
38    pub status: String,
39}
40
41/// Aggregate task counts.
42#[derive(Debug, Clone, serde::Serialize)]
43pub struct TaskSummary {
44    /// Total number of tasks.
45    pub total: usize,
46    /// Tasks waiting to be assigned.
47    pub pending: usize,
48    /// Tasks currently being executed.
49    pub running: usize,
50    /// Tasks that finished successfully.
51    pub completed: usize,
52    /// Tasks that terminated with an error.
53    pub failed: usize,
54    /// Tasks awaiting human review.
55    pub needs_review: usize,
56}
57
58/// Skill for delegating tasks to worker agents via the orchestrator's task queue.
59pub struct AgentDelegateSkill {
60    descriptor: SkillDescriptor,
61    queue: Arc<dyn TaskQueueHandle>,
62}
63
64impl AgentDelegateSkill {
65    /// Create a new `AgentDelegateSkill` backed by the given task queue.
66    pub fn new(queue: Arc<dyn TaskQueueHandle>) -> Self {
67        Self {
68            descriptor: SkillDescriptor {
69                name: "agent_delegate".to_string(),
70                description: "Delegate a subtask to a worker agent. Specify the task description, \
71                    target role (spec/coder/tester/reviewer), and optional dependency task IDs."
72                    .to_string(),
73                parameters_schema: serde_json::json!({
74                    "type": "object",
75                    "properties": {
76                        "description": {
77                            "type": "string",
78                            "description": "Description of the subtask to delegate"
79                        },
80                        "role": {
81                            "type": "string",
82                            "enum": ["spec", "coder", "tester", "reviewer"],
83                            "description": "Worker role to assign the task to"
84                        },
85                        "dependencies": {
86                            "type": "array",
87                            "items": { "type": "string" },
88                            "description": "Task IDs that must complete before this task starts"
89                        }
90                    },
91                    "required": ["description", "role"]
92                }),
93                required_capabilities: vec![],
94                requires_approval: false,
95            },
96            queue,
97        }
98    }
99}
100
101#[async_trait]
102impl Skill for AgentDelegateSkill {
103    fn descriptor(&self) -> &SkillDescriptor {
104        &self.descriptor
105    }
106
107    async fn execute(&self, call: ToolCall) -> ArgentorResult<ToolResult> {
108        let description = call.arguments["description"]
109            .as_str()
110            .unwrap_or("")
111            .to_string();
112        let role = call.arguments["role"].as_str().unwrap_or("").to_string();
113
114        if description.is_empty() {
115            return Ok(ToolResult::error(&call.id, "Task description is required"));
116        }
117        if role.is_empty() {
118            return Ok(ToolResult::error(&call.id, "Role is required"));
119        }
120
121        let valid_roles = ["spec", "coder", "tester", "reviewer"];
122        if !valid_roles.contains(&role.as_str()) {
123            return Ok(ToolResult::error(
124                &call.id,
125                format!("Invalid role '{role}'. Must be one of: {valid_roles:?}"),
126            ));
127        }
128
129        let dependencies: Vec<String> = call.arguments["dependencies"]
130            .as_array()
131            .map(|arr| {
132                arr.iter()
133                    .filter_map(|v| v.as_str().map(String::from))
134                    .collect()
135            })
136            .unwrap_or_default();
137
138        let task_id = self
139            .queue
140            .add_task(description.clone(), role.clone(), dependencies)
141            .await?;
142
143        Ok(ToolResult::success(
144            &call.id,
145            serde_json::json!({
146                "delegated": true,
147                "task_id": task_id,
148                "role": role,
149                "description": description
150            })
151            .to_string(),
152        ))
153    }
154}
155
156#[cfg(test)]
157#[allow(clippy::unwrap_used, clippy::expect_used)]
158mod tests {
159    use super::*;
160    use std::sync::atomic::{AtomicUsize, Ordering};
161    use tokio::sync::RwLock;
162
163    /// Mock TaskQueueHandle for testing.
164    struct MockQueue {
165        tasks: RwLock<Vec<TaskInfo>>,
166        counter: AtomicUsize,
167    }
168
169    impl MockQueue {
170        fn new() -> Self {
171            Self {
172                tasks: RwLock::new(Vec::new()),
173                counter: AtomicUsize::new(1),
174            }
175        }
176    }
177
178    #[async_trait]
179    impl TaskQueueHandle for MockQueue {
180        async fn add_task(
181            &self,
182            description: String,
183            role: String,
184            _dependencies: Vec<String>,
185        ) -> ArgentorResult<String> {
186            let id = format!("task-{}", self.counter.fetch_add(1, Ordering::SeqCst));
187            let mut tasks = self.tasks.write().await;
188            tasks.push(TaskInfo {
189                id: id.clone(),
190                description,
191                role,
192                status: "pending".to_string(),
193            });
194            Ok(id)
195        }
196
197        async fn get_task_info(&self, task_id: &str) -> ArgentorResult<Option<TaskInfo>> {
198            let tasks = self.tasks.read().await;
199            Ok(tasks.iter().find(|t| t.id == task_id).cloned())
200        }
201
202        async fn list_tasks(&self) -> ArgentorResult<Vec<TaskInfo>> {
203            Ok(self.tasks.read().await.clone())
204        }
205
206        async fn task_summary(&self) -> ArgentorResult<TaskSummary> {
207            let tasks = self.tasks.read().await;
208            Ok(TaskSummary {
209                total: tasks.len(),
210                pending: tasks.iter().filter(|t| t.status == "pending").count(),
211                running: 0,
212                completed: 0,
213                failed: 0,
214                needs_review: 0,
215            })
216        }
217    }
218
219    #[tokio::test]
220    async fn test_delegate_task() {
221        let queue = Arc::new(MockQueue::new());
222        let skill = AgentDelegateSkill::new(queue.clone());
223        let call = ToolCall {
224            id: "t1".to_string(),
225            name: "agent_delegate".to_string(),
226            arguments: serde_json::json!({
227                "description": "Write unit tests",
228                "role": "tester"
229            }),
230        };
231        let result = skill.execute(call).await.unwrap();
232        assert!(!result.is_error);
233        let parsed: serde_json::Value = serde_json::from_str(&result.content).unwrap();
234        assert_eq!(parsed["delegated"], true);
235        assert_eq!(parsed["role"], "tester");
236
237        let tasks = queue.list_tasks().await.unwrap();
238        assert_eq!(tasks.len(), 1);
239        assert_eq!(tasks[0].description, "Write unit tests");
240    }
241
242    #[tokio::test]
243    async fn test_delegate_with_dependencies() {
244        let queue = Arc::new(MockQueue::new());
245        let skill = AgentDelegateSkill::new(queue.clone());
246        let call = ToolCall {
247            id: "t2".to_string(),
248            name: "agent_delegate".to_string(),
249            arguments: serde_json::json!({
250                "description": "Implement feature",
251                "role": "coder",
252                "dependencies": ["task-1", "task-2"]
253            }),
254        };
255        let result = skill.execute(call).await.unwrap();
256        assert!(!result.is_error);
257    }
258
259    #[tokio::test]
260    async fn test_delegate_empty_description_error() {
261        let queue = Arc::new(MockQueue::new());
262        let skill = AgentDelegateSkill::new(queue);
263        let call = ToolCall {
264            id: "t3".to_string(),
265            name: "agent_delegate".to_string(),
266            arguments: serde_json::json!({
267                "description": "",
268                "role": "coder"
269            }),
270        };
271        let result = skill.execute(call).await.unwrap();
272        assert!(result.is_error);
273    }
274
275    #[tokio::test]
276    async fn test_delegate_invalid_role_error() {
277        let queue = Arc::new(MockQueue::new());
278        let skill = AgentDelegateSkill::new(queue);
279        let call = ToolCall {
280            id: "t4".to_string(),
281            name: "agent_delegate".to_string(),
282            arguments: serde_json::json!({
283                "description": "Do something",
284                "role": "manager"
285            }),
286        };
287        let result = skill.execute(call).await.unwrap();
288        assert!(result.is_error);
289    }
290}