Compare commits

...
Author SHA1 Message Date
Alex Emmet
b60fb7a496 Merge origin/main 2026-07-09 23:04:34 +02:00
Alex Emmet
507a04a5ff tools & tasks 2026-07-09 23:04:33 +02:00
4 changed files with 200 additions and 33 deletions

View file

@ -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,

View file

@ -210,7 +210,7 @@ pub struct AgentTaskSpec {
#[serde(default)]
pub tools_json: Option<String>,
/* 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<String>,
}

View file

@ -39,6 +39,7 @@ pub struct ToDo {
pub automation_chain_id: Option<Uuid>,
pub tags: Vec<String>,
pub estimated_tokens: u32,
pub tools_json: Option<String>,
}
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<Uuid, Vec<Uuid>>) -> 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<Uuid, Vec<Uuid>>) -> Option<Uuid> {
@ -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<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(
&mut self,
title: Option<String>,
@ -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(_))
));
}
}

View file

@ -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<ToolDefinition> {
"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<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
}
@ -1721,7 +1834,7 @@ pub fn tool_ids_for_agent_type(agent_type: &crate::agents::AgentType) -> Option<
.collect::<Vec<String>>()
};
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. */