tools & tasks

This commit is contained in:
Alex Emmet 2026-07-09 23:04:33 +02:00
commit 507a04a5ff
4 changed files with 200 additions and 33 deletions

View file

@ -88,7 +88,7 @@ impl AgentPolicy {
AgentType::TestJudge => Self { AgentType::TestJudge => Self {
agent_type: agent_type.clone(), agent_type: agent_type.clone(),
auto_approve: true, auto_approve: true,
max_depth: 3, max_depth: 2,
require_preview: true, require_preview: true,
timeout_seconds: 60, timeout_seconds: 60,
max_tokens: 4096, max_tokens: 4096,
@ -98,7 +98,7 @@ impl AgentPolicy {
AgentType::BuildAgent => Self { AgentType::BuildAgent => Self {
agent_type: agent_type.clone(), agent_type: agent_type.clone(),
auto_approve: false, auto_approve: false,
max_depth: 5, max_depth: 3,
require_preview: false, require_preview: false,
timeout_seconds: 300, timeout_seconds: 300,
max_tokens: 16384, max_tokens: 16384,

View file

@ -39,6 +39,7 @@ pub struct ToDo {
pub automation_chain_id: Option<Uuid>, pub automation_chain_id: Option<Uuid>,
pub tags: Vec<String>, pub tags: Vec<String>,
pub estimated_tokens: u32, pub estimated_tokens: u32,
pub tools_json: Option<String>,
} }
impl Default for ToDo { impl Default for ToDo {
@ -64,6 +65,7 @@ impl Default for ToDo {
automation_chain_id: None, automation_chain_id: None,
tags: Vec::new(), tags: Vec::new(),
estimated_tokens: 0, estimated_tokens: 0,
tools_json: None,
} }
} }
} }
@ -91,6 +93,7 @@ impl ToDo {
automation_chain_id: None, automation_chain_id: None,
tags: Vec::new(), tags: Vec::new(),
estimated_tokens: 0, estimated_tokens: 0,
tools_json: None,
} }
} }
@ -99,7 +102,7 @@ impl ToDo {
self self
} }
pub fn validate(&self) -> Result<(), ValidationError> { pub fn validate(&self, deps: &std::collections::HashMap<Uuid, Vec<Uuid>>) -> Result<(), ValidationError> {
if self.title.is_empty() { if self.title.is_empty() {
return Err(ValidationError::EmptyTitle); return Err(ValidationError::EmptyTitle);
} }
@ -116,21 +119,13 @@ impl ToDo {
return Err(ValidationError::InvalidPriority); return Err(ValidationError::InvalidPriority);
} }
if self.has_circular_dependency() { if let Some(cycle_node) = Self::detect_cycle_in_graph(deps) {
return Err(ValidationError::CircularDependency(self.id.to_string())); return Err(ValidationError::CircularDependency(cycle_node.to_string()));
} }
Ok(()) 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 /* DFS cycle detection over a full dependency graph. Returns the first
todo_id found in a cycle, or None when the graph is acyclic. */ todo_id found in a cycle, or None when the graph is acyclic. */
pub fn detect_cycle_in_graph(deps: &std::collections::HashMap<Uuid, Vec<Uuid>>) -> Option<Uuid> { pub fn detect_cycle_in_graph(deps: &std::collections::HashMap<Uuid, Vec<Uuid>>) -> Option<Uuid> {
@ -196,6 +191,7 @@ impl ToDo {
(ToDoStatus::Delegated, ToDoStatus::Completed) => true, (ToDoStatus::Delegated, ToDoStatus::Completed) => true,
(ToDoStatus::Delegated, ToDoStatus::Blocked) => true, (ToDoStatus::Delegated, ToDoStatus::Blocked) => true,
(ToDoStatus::Failed, ToDoStatus::Pending) => true, (ToDoStatus::Failed, ToDoStatus::Pending) => true,
(ToDoStatus::Failed, ToDoStatus::ReadyForAgent) => true,
(ToDoStatus::PendingApproval, ToDoStatus::InProgress) => true, (ToDoStatus::PendingApproval, ToDoStatus::InProgress) => true,
(ToDoStatus::PendingApproval, ToDoStatus::Blocked) => true, (ToDoStatus::PendingApproval, ToDoStatus::Blocked) => true,
_ => self.status == status, _ => self.status == status,
@ -213,6 +209,25 @@ impl ToDo {
Ok(()) 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<Uuid>,
) -> 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( pub fn update(
&mut self, &mut self,
title: Option<String>, title: Option<String>,
@ -270,40 +285,42 @@ mod tests {
#[test] #[test]
fn test_todo_validate_empty_title() { fn test_todo_validate_empty_title() {
let mut todo = ToDo::default(); let todo = ToDo::default();
todo.title = "".to_string(); let deps = std::collections::HashMap::new();
assert!(matches!(todo.validate(), Err(ValidationError::EmptyTitle))); assert!(matches!(todo.validate(&deps), Err(ValidationError::EmptyTitle)));
} }
#[test] #[test]
fn test_todo_validate_title_too_long() { fn test_todo_validate_title_too_long() {
let mut todo = ToDo::default(); let mut todo = ToDo::default();
todo.title = "a".repeat(MAX_TITLE_LENGTH + 1); todo.title = "a".repeat(MAX_TITLE_LENGTH + 1);
let deps = std::collections::HashMap::new();
assert!(matches!( assert!(matches!(
todo.validate(), todo.validate(&deps),
Err(ValidationError::TitleTooLong) Err(ValidationError::TitleTooLong)
)); ));
} }
#[test] #[test]
fn test_todo_validate_priority() { 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()); let mut todo = ToDo::new("Test".to_string(), "desc".to_string(), Uuid::new_v4());
todo.priority = 0; todo.priority = 0;
assert!(matches!( assert!(matches!(
todo.validate(), todo.validate(&deps),
Err(ValidationError::InvalidPriority) Err(ValidationError::InvalidPriority)
)); ));
let mut todo = ToDo::new("Test".to_string(), "desc".to_string(), Uuid::new_v4()); let mut todo = ToDo::new("Test".to_string(), "desc".to_string(), Uuid::new_v4());
todo.priority = 1001; todo.priority = 1001;
assert!(matches!( assert!(matches!(
todo.validate(), todo.validate(&deps),
Err(ValidationError::InvalidPriority) Err(ValidationError::InvalidPriority)
)); ));
let mut todo = ToDo::new("Test".to_string(), "desc".to_string(), Uuid::new_v4()); let mut todo = ToDo::new("Test".to_string(), "desc".to_string(), Uuid::new_v4());
todo.priority = 500; todo.priority = 500;
assert!(todo.validate().is_ok()); assert!(todo.validate(&deps).is_ok());
} }
#[test] #[test]
@ -375,16 +392,19 @@ mod tests {
} }
#[test] #[test]
fn test_has_circular_dependency_self_reference() { fn test_detect_cycle_in_graph_self_reference() {
let mut todo = ToDo::default(); let id = Uuid::new_v4();
todo.depends_on = vec![todo.id]; let mut deps = std::collections::HashMap::new();
assert!(todo.has_circular_dependency()); deps.insert(id, vec![id]);
assert!(ToDo::detect_cycle_in_graph(&deps).is_some());
} }
#[test] #[test]
fn test_has_circular_dependency_none() { fn test_detect_cycle_in_graph_none() {
let todo = ToDo::default(); let id = Uuid::new_v4();
assert!(!todo.has_circular_dependency()); let mut deps = std::collections::HashMap::new();
deps.insert(id, vec![]);
assert!(ToDo::detect_cycle_in_graph(&deps).is_none());
} }
#[test] #[test]
@ -406,4 +426,20 @@ mod tests {
deps.insert(id_b, vec![]); deps.insert(id_b, vec![]);
assert!(ToDo::detect_cycle_in_graph(&deps).is_none()); 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(_))
));
}
} }

View file

@ -97,6 +97,11 @@ pub struct ToolDefinition {
/* Caller surface (chat/planner/agent) in which this tool may appear. */ /* Caller surface (chat/planner/agent) in which this tool may appear. */
#[serde(default)] #[serde(default)]
pub context_visibility: ContextVisibility, 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 { const fn default_timeout_ms() -> u64 {
@ -119,6 +124,7 @@ impl Default for ToolDefinition {
deprecation_message: String::new(), deprecation_message: String::new(),
execution_context: ExecutionContext::default(), execution_context: ExecutionContext::default(),
context_visibility: ContextVisibility::default(), context_visibility: ContextVisibility::default(),
required_depth: 0,
} }
} }
} }
@ -828,6 +834,7 @@ pub struct ToolDocumentation {
pub const PLANNER_ONLY_TOOLS: &[&str] = &[ pub const PLANNER_ONLY_TOOLS: &[&str] = &[
"change_planner_mode", "change_planner_mode",
"propose_strategic_item", "propose_strategic_item",
"split_task",
"audit_assumptions", "audit_assumptions",
"identify_blind_spots", "identify_blind_spots",
"check_dependencies", "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 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. */ Reef via MTP and are NOT in this list they have no local implementation. */
pub const REEF_PROXY_TOOLS: &[&str] = &[ pub const REEF_PROXY_TOOLS: &[&str] = &[
"read_file", "file_read", "read_file",
"write_file", "file_write", "file_read",
"write_file",
"file_write",
"edit_file", "edit_file",
"list_directory", "list_directory",
"search_files", "search_files",
"grep", "grep",
"bash", "execute", "bash",
"execute",
"delete_workspace", "delete_workspace",
"list_files", "list_files",
]; ];
@ -1539,6 +1549,34 @@ pub fn tool_definitions() -> Vec<ToolDefinition> {
"required": ["project_id", "title", "description", "rationale", "proposer_id"] "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!( tool!(
"change_planner_mode", "change_planner_mode",
"Switch the Planner's working mode. Modes: cooperative (fast shipping), critical (audit), red_team (pre-mortem), socratic (discovery), execution (task decomposition).", "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<ToolDefinition> {
} }
} }
/* 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 tools
} }
@ -1721,7 +1834,7 @@ pub fn tool_ids_for_agent_type(agent_type: &crate::agents::AgentType) -> Option<
.collect::<Vec<String>>() .collect::<Vec<String>>()
}; };
Some(match agent_type { let ids = Some(match agent_type {
crate::agents::AgentType::BuildAgent => curated(&[ crate::agents::AgentType::BuildAgent => curated(&[
"read_file", "read_file",
"write_file", "write_file",
@ -1777,7 +1890,25 @@ pub fn tool_ids_for_agent_type(agent_type: &crate::agents::AgentType) -> Option<
"web_search", "web_search",
"web_fetch", "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. */ /* Formats tool definitions as OpenAI-compatible function-calling JSON. */