From 507a04a5ff0cf30579355700615820f6f73435b2 Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:04:33 +0200 Subject: [PATCH] tools & tasks --- src/agents.rs | 4 +- src/task.rs | 2 +- src/todo.rs | 86 +++++++++++++++++++++--------- src/tools.rs | 141 ++++++++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 200 insertions(+), 33 deletions(-) diff --git a/src/agents.rs b/src/agents.rs index a3e862f..07c5d60 100644 --- a/src/agents.rs +++ b/src/agents.rs @@ -88,7 +88,7 @@ impl AgentPolicy { AgentType::TestJudge => Self { agent_type: agent_type.clone(), auto_approve: true, - max_depth: 3, + max_depth: 2, require_preview: true, timeout_seconds: 60, max_tokens: 4096, @@ -98,7 +98,7 @@ impl AgentPolicy { AgentType::BuildAgent => Self { agent_type: agent_type.clone(), auto_approve: false, - max_depth: 5, + max_depth: 3, require_preview: false, timeout_seconds: 300, max_tokens: 16384, diff --git a/src/task.rs b/src/task.rs index b00d177..aaeece5 100644 --- a/src/task.rs +++ b/src/task.rs @@ -210,7 +210,7 @@ pub struct AgentTaskSpec { #[serde(default)] pub tools_json: Option, /* Overrides the agent-type default system prompt when set. Takes highest - precedence in the resolution chain (above DB overrides and defaults). */ + precedence in the resolution chain (above DB overrides and defaults). */ #[serde(default)] pub system_prompt: Option, } diff --git a/src/todo.rs b/src/todo.rs index 213b003..6586a7c 100644 --- a/src/todo.rs +++ b/src/todo.rs @@ -39,6 +39,7 @@ pub struct ToDo { pub automation_chain_id: Option, pub tags: Vec, pub estimated_tokens: u32, + pub tools_json: Option, } impl Default for ToDo { @@ -64,6 +65,7 @@ impl Default for ToDo { automation_chain_id: None, tags: Vec::new(), estimated_tokens: 0, + tools_json: None, } } } @@ -91,6 +93,7 @@ impl ToDo { automation_chain_id: None, tags: Vec::new(), estimated_tokens: 0, + tools_json: None, } } @@ -99,7 +102,7 @@ impl ToDo { self } - pub fn validate(&self) -> Result<(), ValidationError> { + pub fn validate(&self, deps: &std::collections::HashMap>) -> Result<(), ValidationError> { if self.title.is_empty() { return Err(ValidationError::EmptyTitle); } @@ -116,21 +119,13 @@ impl ToDo { return Err(ValidationError::InvalidPriority); } - if self.has_circular_dependency() { - return Err(ValidationError::CircularDependency(self.id.to_string())); + if let Some(cycle_node) = Self::detect_cycle_in_graph(deps) { + return Err(ValidationError::CircularDependency(cycle_node.to_string())); } Ok(()) } - fn has_circular_dependency(&self) -> bool { - // Direct self-dependency - if self.depends_on.contains(&self.id) { - return true; - } - false - } - /* DFS cycle detection over a full dependency graph. Returns the first todo_id found in a cycle, or None when the graph is acyclic. */ pub fn detect_cycle_in_graph(deps: &std::collections::HashMap>) -> Option { @@ -196,6 +191,7 @@ impl ToDo { (ToDoStatus::Delegated, ToDoStatus::Completed) => true, (ToDoStatus::Delegated, ToDoStatus::Blocked) => true, (ToDoStatus::Failed, ToDoStatus::Pending) => true, + (ToDoStatus::Failed, ToDoStatus::ReadyForAgent) => true, (ToDoStatus::PendingApproval, ToDoStatus::InProgress) => true, (ToDoStatus::PendingApproval, ToDoStatus::Blocked) => true, _ => self.status == status, @@ -213,6 +209,25 @@ impl ToDo { Ok(()) } + /// Walk the parent_todo_id chain and return the depth (1 = root, 2 = child, etc.) + pub fn calculate_depth( + todo_id: Uuid, + get_parent: impl Fn(Uuid) -> Option, + ) -> usize { + let mut depth = 1usize; + let mut current_id = get_parent(todo_id); + let mut visited = HashSet::new(); + + while let Some(pid) = current_id { + if !visited.insert(pid) { + break; // cycle detected + } + depth += 1; + current_id = get_parent(pid); + } + depth + } + pub fn update( &mut self, title: Option, @@ -270,40 +285,42 @@ mod tests { #[test] fn test_todo_validate_empty_title() { - let mut todo = ToDo::default(); - todo.title = "".to_string(); - assert!(matches!(todo.validate(), Err(ValidationError::EmptyTitle))); + let todo = ToDo::default(); + let deps = std::collections::HashMap::new(); + assert!(matches!(todo.validate(&deps), Err(ValidationError::EmptyTitle))); } #[test] fn test_todo_validate_title_too_long() { let mut todo = ToDo::default(); todo.title = "a".repeat(MAX_TITLE_LENGTH + 1); + let deps = std::collections::HashMap::new(); assert!(matches!( - todo.validate(), + todo.validate(&deps), Err(ValidationError::TitleTooLong) )); } #[test] fn test_todo_validate_priority() { + let deps = std::collections::HashMap::new(); let mut todo = ToDo::new("Test".to_string(), "desc".to_string(), Uuid::new_v4()); todo.priority = 0; assert!(matches!( - todo.validate(), + todo.validate(&deps), Err(ValidationError::InvalidPriority) )); let mut todo = ToDo::new("Test".to_string(), "desc".to_string(), Uuid::new_v4()); todo.priority = 1001; assert!(matches!( - todo.validate(), + todo.validate(&deps), Err(ValidationError::InvalidPriority) )); let mut todo = ToDo::new("Test".to_string(), "desc".to_string(), Uuid::new_v4()); todo.priority = 500; - assert!(todo.validate().is_ok()); + assert!(todo.validate(&deps).is_ok()); } #[test] @@ -375,16 +392,19 @@ mod tests { } #[test] - fn test_has_circular_dependency_self_reference() { - let mut todo = ToDo::default(); - todo.depends_on = vec![todo.id]; - assert!(todo.has_circular_dependency()); + fn test_detect_cycle_in_graph_self_reference() { + let id = Uuid::new_v4(); + let mut deps = std::collections::HashMap::new(); + deps.insert(id, vec![id]); + assert!(ToDo::detect_cycle_in_graph(&deps).is_some()); } #[test] - fn test_has_circular_dependency_none() { - let todo = ToDo::default(); - assert!(!todo.has_circular_dependency()); + fn test_detect_cycle_in_graph_none() { + let id = Uuid::new_v4(); + let mut deps = std::collections::HashMap::new(); + deps.insert(id, vec![]); + assert!(ToDo::detect_cycle_in_graph(&deps).is_none()); } #[test] @@ -406,4 +426,20 @@ mod tests { deps.insert(id_b, vec![]); assert!(ToDo::detect_cycle_in_graph(&deps).is_none()); } + + #[test] + fn test_validate_catches_indirect_cycle() { + let id_a = Uuid::new_v4(); + let id_b = Uuid::new_v4(); + let mut todo = ToDo::new("Test".to_string(), "desc".to_string(), Uuid::new_v4()); + todo.id = id_a; + todo.depends_on = vec![id_b]; + let mut deps = std::collections::HashMap::new(); + deps.insert(id_a, vec![id_b]); + deps.insert(id_b, vec![id_a]); + assert!(matches!( + todo.validate(&deps), + Err(ValidationError::CircularDependency(_)) + )); + } } diff --git a/src/tools.rs b/src/tools.rs index 069ddaf..631e13c 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -97,6 +97,11 @@ pub struct ToolDefinition { /* Caller surface (chat/planner/agent) in which this tool may appear. */ #[serde(default)] pub context_visibility: ContextVisibility, + /* Minimum agent depth required for this tool to be visible. + 0 = visible to all agents. + 1+ = only visible to agents with max_depth >= this value. */ + #[serde(default)] + pub required_depth: u32, } const fn default_timeout_ms() -> u64 { @@ -119,6 +124,7 @@ impl Default for ToolDefinition { deprecation_message: String::new(), execution_context: ExecutionContext::default(), context_visibility: ContextVisibility::default(), + required_depth: 0, } } } @@ -828,6 +834,7 @@ pub struct ToolDocumentation { pub const PLANNER_ONLY_TOOLS: &[&str] = &[ "change_planner_mode", "propose_strategic_item", + "split_task", "audit_assumptions", "identify_blind_spots", "check_dependencies", @@ -852,13 +859,16 @@ This is the Krill → Reef proxy set, not the full set of Reef-only tools. Tools like kanban_*, document_*, and strategic items are always executed on Reef via MTP and are NOT in this list — they have no local implementation. */ pub const REEF_PROXY_TOOLS: &[&str] = &[ - "read_file", "file_read", - "write_file", "file_write", + "read_file", + "file_read", + "write_file", + "file_write", "edit_file", "list_directory", "search_files", "grep", - "bash", "execute", + "bash", + "execute", "delete_workspace", "list_files", ]; @@ -1539,6 +1549,34 @@ pub fn tool_definitions() -> Vec { "required": ["project_id", "title", "description", "rationale", "proposer_id"] }) ), + tool!( + "split_task", + "Split a Pending todo into multiple subtasks with proper dependency tracking, depth validation, and cycle detection. Each subtask should be independently deployable to an agent.", + "strategic", + false, false, true, + serde_json::json!({ + "type": "object", + "properties": { + "project_id": { "type": "string", "description": "Project ID" }, + "parent_todo_id": { "type": "string", "description": "ID of the todo to split (must be in Pending status)" }, + "subtasks": { + "type": "array", + "description": "Array of subtask definitions", + "items": { + "type": "object", + "properties": { + "title": { "type": "string", "description": "Title of the subtask" }, + "description": { "type": "string", "description": "Detailed description" }, + "depends_on": { "type": "array", "items": { "type": "integer" }, "description": "Indices (0-based) of subtasks this one depends on" }, + "agent_type_hint": { "type": "string", "description": "Optional agent type hint (build_agent, doc_agent, explore_agent, test_judge)" } + }, + "required": ["title"] + } + } + }, + "required": ["project_id", "parent_todo_id", "subtasks"] + }) + ), tool!( "change_planner_mode", "Switch the Planner's working mode. Modes: cooperative (fast shipping), critical (audit), red_team (pre-mortem), socratic (discovery), execution (task decomposition).", @@ -1700,6 +1738,81 @@ pub fn tool_definitions() -> Vec { } } + /* Assign required_depth per tool. Depth 0 = visible to all agents. + Depth 1 = needs one planning cycle (bash, write/edits, build tools). + Depth 2 = needs moderate depth (kanban mutation, workspace creation). + Depth 3 = needs deep reasoning (strategic, batch planning). */ + let depth_map: std::collections::HashMap<&str, u32> = [ + // Depth 0 — always visible + ("read_file", 0), + ("file_read", 0), + ("list_directory", 0), + ("search_files", 0), + ("list_files", 0), + ("grep", 0), + ("web_search", 0), + ("web_fetch", 0), + ("web_api", 0), + ("workspace_info", 0), + ("kanban_list_board", 0), + ("kanban_add_tag", 0), + ("git_status", 0), + ("git_diff", 0), + ("git_log", 0), + ("git_branch", 0), + ("get_documentation_context", 0), + ("documentation_tree", 0), + ("verify_documentation", 0), + ("find_references", 0), + ("file_dependencies", 0), + ("audit_assumptions", 0), + ("identify_blind_spots", 0), + ("check_dependencies", 0), + ("evaluate_plan_risk", 0), + ("compare_project_patterns", 0), + ("find_similar_risks", 0), + ("report_completion", 0), + // Depth 1 — needs one planning cycle + ("write_file", 1), + ("file_write", 1), + ("edit_file", 1), + ("bash", 1), + ("execute", 1), + ("cargo_check", 1), + ("npm_build", 1), + ("python_check", 1), + ("test_runner", 1), + ("delete_workspace", 1), + ("create_venv", 1), + ("install_dependencies", 1), + ("document_file", 1), + ("document_project", 1), + ("document_folder", 1), + ("store_file_doc", 1), + // Depth 2 — needs moderate depth + ("kanban_create_todo", 2), + ("kanban_update_todo", 2), + ("kanban_delete_todo", 2), + ("kanban_move_todo", 2), + ("kanban_create_task", 2), + ("kanban_update_task", 2), + ("create_workspace", 2), + ("split_task", 2), + // Depth 3 — needs deep reasoning + ("submit_batch_plan", 3), + ("change_planner_mode", 3), + ("propose_strategic_item", 3), + ] + .iter() + .cloned() + .collect(); + + for tool in tools.iter_mut() { + if let Some(&depth) = depth_map.get(tool.id.as_str()) { + tool.required_depth = depth; + } + } + tools } @@ -1721,7 +1834,7 @@ pub fn tool_ids_for_agent_type(agent_type: &crate::agents::AgentType) -> Option< .collect::>() }; - Some(match agent_type { + let ids = Some(match agent_type { crate::agents::AgentType::BuildAgent => curated(&[ "read_file", "write_file", @@ -1777,7 +1890,25 @@ pub fn tool_ids_for_agent_type(agent_type: &crate::agents::AgentType) -> Option< "web_search", "web_fetch", ]), - }) + }); + + // Filter by depth: remove tools whose required_depth exceeds the agent's max_depth + let policy = crate::agents::AgentPolicy::defaults_for(agent_type); + let max_depth = policy.max_depth; + let all_tools = tool_definitions(); + let depth_map: std::collections::HashMap<&str, u32> = all_tools + .iter() + .map(|t| (t.id.as_str(), t.required_depth)) + .collect(); + + ids.retain(|id| { + depth_map + .get(id.as_str()) + .map(|&req| req <= max_depth) + .unwrap_or(true) + }); + + Some(ids) } /* Formats tool definitions as OpenAI-compatible function-calling JSON. */