From ae6375a1d9a4c5e592a507b5f9a1fcc1abbb6c72 Mon Sep 17 00:00:00 2001 From: Alex Emmet <111742636+Alex-Emmet@users.noreply.github.com> Date: Mon, 6 Jul 2026 00:43:55 +0200 Subject: [PATCH] Uploads, Sync, Changetracking, Pods throttle --- src/lib.rs | 4 +- src/tools.rs | 377 ++++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 327 insertions(+), 54 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 7bc955c..f479187 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -32,8 +32,8 @@ pub use task::{Task, TaskResult}; pub use todo::{Dependency, ToDo}; pub use tools::{ format_tool_error, format_tools_json, format_tool_result, parse_tool_call_blocks, - parse_tool_call_stream, tool_definitions, tool_ids_for_agent_type, CompositionStep, - ExecutionContext, MarketplaceSearchResults, ParsedToolCall, + parse_tool_call_stream, tool_definitions, tool_ids_for_agent_type, ApprovalRequirement, + CompositionStep, ContextVisibility, ExecutionContext, MarketplaceSearchResults, ParsedToolCall, TestAction, ToolComposition, ToolDefinition, ToolDependency, ToolDocumentation, ToolExample, ToolListing, ToolParser, ToolPlugin, ToolTest, ToolTestResult, ToolTestSuite, ToolTestSuiteResult, ToolVersion, diff --git a/src/tools.rs b/src/tools.rs index 9548f67..8f7beb0 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -16,6 +16,53 @@ impl Default for ExecutionContext { } } +/* Which caller surface a tool may appear in, independent of where it executes. +`ExecutionContext` decides which runtime runs a tool; `ContextVisibility` +decides which surface (direct chat, planner session, or agent run) is allowed +to see and invoke it. */ +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ContextVisibility { + /* Visible everywhere: chat, planner, and agent runs. */ + Everywhere, + /* Only visible inside a planner session (strategic tooling). */ + PlannerOnly, + /* Only visible to automated agent runs; hidden from direct chat and planner. */ + AgentOnly, +} + +impl Default for ContextVisibility { + fn default() -> Self { + Self::Everywhere + } +} + +/* How a tool call is gated before it runs. `None` runs immediately; `Policy` +consults the per-agent Kelp policy and precedent gate (a human or agent-flow +approval may follow); `Human` always reaches a person. This replaces the legacy +`dangerous`/`requires_approval` bool pair. Whether a tool *mutates* durable +state is a separate axis exposed by `ToolDefinition::mutates()`. */ +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum ApprovalRequirement { + #[default] + None, + Policy, + Human, +} + +impl ApprovalRequirement { + /* Bridges callers that still describe a tool with the old two-bool intent + (dangerous, requires_approval): either flag means the call is policy-gated. */ + pub fn from_flags(dangerous: bool, requires_approval: bool) -> Self { + if dangerous || requires_approval { + ApprovalRequirement::Policy + } else { + ApprovalRequirement::None + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ToolDefinition { #[serde(default)] @@ -25,10 +72,9 @@ pub struct ToolDefinition { #[serde(default)] pub category: String, pub parameters: serde_json::Value, + /* How this tool is gated before it runs. Defaults to `None` (runs freely). */ #[serde(default)] - pub dangerous: bool, - #[serde(default)] - pub requires_approval: bool, + pub approval_requirement: ApprovalRequirement, #[serde(default)] pub version: String, /* Whether this tool requires a project context to function. @@ -47,6 +93,9 @@ pub struct ToolDefinition { /* Execution context indicating where this tool should be available */ #[serde(default)] pub execution_context: ExecutionContext, + /* Caller surface (chat/planner/agent) in which this tool may appear. */ + #[serde(default)] + pub context_visibility: ContextVisibility, } const fn default_timeout_ms() -> u64 { @@ -61,19 +110,34 @@ impl Default for ToolDefinition { description: String::new(), category: String::new(), parameters: serde_json::Value::Null, - dangerous: false, - requires_approval: false, + approval_requirement: ApprovalRequirement::None, version: String::new(), project_scoped: false, timeout_ms: default_timeout_ms(), deprecated: false, deprecation_message: String::new(), execution_context: ExecutionContext::default(), + context_visibility: ContextVisibility::default(), } } } impl ToolDefinition { + /* Tools that mutate durable state (filesystem or shell). Kept as a name-derived + axis separate from the approval taxonomy: it drives the Lookout "dangerous" + badge and the Phase 8 snapshot-before-mutation gate. */ + pub fn mutates(&self) -> bool { + matches!( + self.name.as_str(), + "write_file" | "edit_file" | "bash" | "delete_workspace" + ) + } + + /* True when the call passes through any approval gate at all. */ + pub fn requires_approval(&self) -> bool { + self.approval_requirement != ApprovalRequirement::None + } + pub fn to_openai_format(&self) -> serde_json::Value { serde_json::json!({ "type": "function", @@ -408,7 +472,7 @@ pub struct ToolErrorInfo { } /* Extended version tracking for tool definitions. - Provides schema versioning, deprecation notices, and migration guidance. */ +Provides schema versioning, deprecation notices, and migration guidance. */ #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ToolVersion { pub version: String, @@ -432,7 +496,7 @@ impl Default for ToolVersion { } /* Execution context for tool filtering. Determines which tools are - available in which execution environment (Reef vs. Krill vs. both). */ +available in which execution environment (Reef vs. Krill vs. both). */ #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ToolContext { @@ -483,7 +547,7 @@ impl ToolCategory { } /* A composed workflow of multiple tool steps executed in sequence - with dependency-based ordering. */ +with dependency-based ordering. */ #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ToolComposition { pub id: String, @@ -557,14 +621,14 @@ macro_rules! tool { description: $desc.to_string(), category: $cat.to_string(), parameters: $params, - dangerous: $dangerous, - requires_approval: $approval, + approval_requirement: ApprovalRequirement::from_flags($dangerous, $approval), version: "1.0.0".to_string(), project_scoped: $scoped, timeout_ms: default_timeout_ms(), deprecated: false, deprecation_message: String::new(), execution_context: ExecutionContext::default(), + context_visibility: ContextVisibility::default(), } }; ($name:expr, $desc:expr, $cat:expr, $dangerous:expr, $approval:expr, $scoped:expr, $params:expr, $timeout:expr) => { @@ -574,20 +638,20 @@ macro_rules! tool { description: $desc.to_string(), category: $cat.to_string(), parameters: $params, - dangerous: $dangerous, - requires_approval: $approval, + approval_requirement: ApprovalRequirement::from_flags($dangerous, $approval), version: "1.0.0".to_string(), project_scoped: $scoped, timeout_ms: $timeout, deprecated: false, deprecation_message: String::new(), execution_context: ExecutionContext::default(), + context_visibility: ContextVisibility::default(), } }; } /* Trait for implementing custom tool plugins. - Plugins register at runtime to extend the tool ecosystem. */ +Plugins register at runtime to extend the tool ecosystem. */ pub trait ToolPlugin: Send + Sync { fn name(&self) -> &str; fn description(&self) -> &str; @@ -595,9 +659,13 @@ pub trait ToolPlugin: Send + Sync { fn schema(&self) -> serde_json::Value; fn execute(&self, args: serde_json::Value) -> Result; /* Defaults to true (dangerous) — safe default for unknown plugins. */ - fn dangerous(&self) -> bool { true } + fn dangerous(&self) -> bool { + true + } /* Defaults to true; callers must explicitly opt out of approval prompts. */ - fn requires_approval(&self) -> bool { true } + fn requires_approval(&self) -> bool { + true + } } /* A marketplace listing for a published tool. */ @@ -734,9 +802,27 @@ pub struct ToolDocumentation { pub examples: Vec, } +/* Tools visible only inside a planner session (strategic oversight). */ +pub const PLANNER_ONLY_TOOLS: &[&str] = &[ + "change_planner_mode", + "propose_strategic_item", + "audit_assumptions", + "identify_blind_spots", + "check_dependencies", + "evaluate_plan_risk", + "compare_project_patterns", + "find_similar_risks", +]; + +/* Tools visible only to automated agent runs, hidden from direct chat and planner. +Mutating tools (write_file, edit_file, bash, delete_workspace) are intentionally +NOT here: they stay Everywhere so direct chat can call them under approval routing +(Phase 4). Only genuinely agent-internal collaboration tools are agent-only. */ +pub const AGENT_ONLY_TOOLS: &[&str] = &["submit_batch_plan", "report_completion"]; + /* Returns all built-in tool definitions across all categories. */ pub fn tool_definitions() -> Vec { - vec![ + let mut tools = vec![ // ======================== // Filesystem Tools // ======================== @@ -1398,14 +1484,14 @@ pub fn tool_definitions() -> Vec { ), tool!( "change_planner_mode", - "Suggest switching the Planner's personality mode", + "Switch the Planner's working mode. Modes: cooperative (fast shipping), critical (audit), red_team (pre-mortem), socratic (discovery), execution (task decomposition).", "strategic", false, true, true, serde_json::json!({ "type": "object", "properties": { "project_id": { "type": "string", "description": "Project ID" }, - "mode": { "type": "string", "enum": ["cooperative", "critical", "red_team", "socratic"], "description": "The planner mode to switch to" }, + "mode": { "type": "string", "enum": ["cooperative", "critical", "red_team", "socratic", "execution"], "description": "The planner mode to switch to" }, "rationale": { "type": "string", "description": "Why this mode change is beneficial" } }, "required": ["project_id", "mode", "rationale"] @@ -1534,45 +1620,106 @@ pub fn tool_definitions() -> Vec { "required": ["plan_id", "steps", "rationale"] }) ), - ] + ]; + + /* Apply context visibility. The explicit id lists are the primary source, + but a category backstop makes classification fail-closed: any strategic or + agent-collaboration tool added later is restricted by its category even if + nobody remembered to list it here, so a new sensitive tool cannot silently + leak into the chat surface as the macro's Everywhere default. Every current + strategic tool is already in PLANNER_ONLY_TOOLS and every agent tool in + AGENT_ONLY_TOOLS, so this changes no existing tool's classification. */ + for tool in tools.iter_mut() { + if PLANNER_ONLY_TOOLS.contains(&tool.id.as_str()) { + tool.context_visibility = ContextVisibility::PlannerOnly; + } else if AGENT_ONLY_TOOLS.contains(&tool.id.as_str()) { + tool.context_visibility = ContextVisibility::AgentOnly; + } else { + tool.context_visibility = match tool.category.as_str() { + "strategic" => ContextVisibility::PlannerOnly, + "agent" => ContextVisibility::AgentOnly, + _ => tool.context_visibility, + }; + } + } + + tools } /* Returns the tool IDs appropriate for a given agent type. - Used by the dispatcher to embed tool definitions in task specs. */ -pub fn tool_ids_for_agent_type(agent_type: &crate::agents::AgentType) -> Option> { +Used by the dispatcher to embed tool definitions in task specs. + +Planner and ChatAgent derive their sets from `ContextVisibility` so the tool +list stays a single source of truth: the Planner sees Everywhere + PlannerOnly +tools, ChatAgent sees Everywhere tools only (strategic PlannerOnly and +agent-internal AgentOnly tools stay hidden). The remaining agent types keep a +curated subset that is narrower than any visibility class allows. */ +pub fn tool_ids_for_agent_type(agent_type: &crate::agents::AgentType) -> Option> { + let curated = |ids: &[&str]| ids.iter().map(|s| s.to_string()).collect::>(); + let by_visibility = |allowed: &[ContextVisibility]| { + tool_definitions() + .into_iter() + .filter(|t| allowed.contains(&t.context_visibility)) + .map(|t| t.id) + .collect::>() + }; + Some(match agent_type { - crate::agents::AgentType::BuildAgent => vec![ - "read_file", "write_file", "edit_file", "list_directory", "search_files", - "bash", "cargo_check", "npm_build", "python_check", "test_runner", - "create_workspace", "kanban_create_todo", "kanban_update_todo", - "kanban_create_task", "list_files", "submit_batch_plan", "report_completion", - ], - crate::agents::AgentType::DocAgent => vec![ - "read_file", "search_files", "list_files", "list_directory", - "find_references", "file_dependencies", "get_documentation_context", - "documentation_tree", "store_file_doc", - ], - crate::agents::AgentType::TestJudge => vec![ - "read_file", "search_files", "list_files", "list_directory", - "bash", "kanban_create_todo", "kanban_update_todo", "report_completion", - ], - crate::agents::AgentType::Planner => vec![ - "kanban_list_board", "kanban_create_todo", "kanban_update_todo", - "kanban_delete_todo", "kanban_move_todo", "kanban_create_task", - "kanban_update_task", "kanban_add_tag", "document_file", "document_project", - "find_references", "file_dependencies", "documentation_tree", - "verify_documentation", "document_folder", "get_documentation_context", - "list_files", "propose_strategic_item", "change_planner_mode", - "audit_assumptions", "identify_blind_spots", "check_dependencies", - "evaluate_plan_risk", "compare_project_patterns", "find_similar_risks", - "store_file_doc", "read_file", - ], - crate::agents::AgentType::ChatAgent => return None, + crate::agents::AgentType::BuildAgent => curated(&[ + "read_file", + "write_file", + "edit_file", + "list_directory", + "search_files", + "bash", + "cargo_check", + "npm_build", + "python_check", + "test_runner", + "create_workspace", + "kanban_create_todo", + "kanban_update_todo", + "kanban_create_task", + "list_files", + "submit_batch_plan", + "report_completion", + ]), + /* + * The DocAgent documents the file handed to it inline in the user message, + * so it needs no raw codebase access (read_file/search/list/deps). It gets + * only the doc-context read helpers plus store_file_doc — no write or exec + * tools — which is why the prompt no longer needs a negative "you must not + * edit" instruction. + */ + crate::agents::AgentType::DocAgent => curated(&[ + "get_documentation_context", + "documentation_tree", + "store_file_doc", + ]), + crate::agents::AgentType::TestJudge => curated(&[ + "read_file", + "search_files", + "list_files", + "list_directory", + "bash", + "kanban_create_todo", + "kanban_update_todo", + "report_completion", + ]), + crate::agents::AgentType::Planner => by_visibility(&[ + ContextVisibility::Everywhere, + ContextVisibility::PlannerOnly, + ]), + crate::agents::AgentType::ChatAgent => by_visibility(&[ContextVisibility::Everywhere]), /* ExploreAgent only receives read-only tools — no write, edit, or bash. */ - crate::agents::AgentType::ExploreAgent => vec![ - "read_file", "list_directory", "search_files", "list_files", - "web_search", "web_fetch", - ], + crate::agents::AgentType::ExploreAgent => curated(&[ + "read_file", + "list_directory", + "search_files", + "list_files", + "web_search", + "web_fetch", + ]), }) } @@ -2060,6 +2207,132 @@ Done with tools. assert_eq!(ExecutionContext::default(), ExecutionContext::Both); } + #[test] + fn test_context_visibility_default() { + assert_eq!(ContextVisibility::default(), ContextVisibility::Everywhere); + assert_eq!( + ToolDefinition::default().context_visibility, + ContextVisibility::Everywhere + ); + } + + #[test] + fn test_tool_definitions_context_visibility_classification() { + let tools = tool_definitions(); + let visibility_of = |id: &str| { + tools + .iter() + .find(|t| t.id == id) + .map(|t| t.context_visibility) + }; + + assert_eq!( + visibility_of("change_planner_mode"), + Some(ContextVisibility::PlannerOnly) + ); + assert_eq!( + visibility_of("propose_strategic_item"), + Some(ContextVisibility::PlannerOnly) + ); + assert_eq!( + visibility_of("submit_batch_plan"), + Some(ContextVisibility::AgentOnly) + ); + assert_eq!( + visibility_of("report_completion"), + Some(ContextVisibility::AgentOnly) + ); + /* Mutating tools stay Everywhere so chat can call them under approval routing. */ + assert_eq!( + visibility_of("write_file"), + Some(ContextVisibility::Everywhere) + ); + assert_eq!(visibility_of("bash"), Some(ContextVisibility::Everywhere)); + assert_eq!( + visibility_of("read_file"), + Some(ContextVisibility::Everywhere) + ); + assert_eq!( + visibility_of("kanban_list_board"), + Some(ContextVisibility::Everywhere) + ); + + for id in PLANNER_ONLY_TOOLS { + assert_eq!( + visibility_of(id), + Some(ContextVisibility::PlannerOnly), + "{id} should be PlannerOnly" + ); + } + for id in AGENT_ONLY_TOOLS { + assert_eq!( + visibility_of(id), + Some(ContextVisibility::AgentOnly), + "{id} should be AgentOnly" + ); + } + } + + #[test] + fn test_category_backstop_keeps_sensitive_tools_fail_closed() { + /* Invariant enforced by the category backstop: no strategic tool is ever + chat-visible and no agent-collaboration tool is planner/chat-visible, + regardless of whether it was added to the explicit id lists. A new tool + in either category that leaks as Everywhere fails this test. */ + for tool in tool_definitions() { + match tool.category.as_str() { + "strategic" => assert_eq!( + tool.context_visibility, + ContextVisibility::PlannerOnly, + "strategic tool {} must not be chat-visible", + tool.id + ), + "agent" => assert_eq!( + tool.context_visibility, + ContextVisibility::AgentOnly, + "agent tool {} must not be chat/planner-visible", + tool.id + ), + _ => {} + } + } + } + + #[test] + fn test_tool_ids_for_agent_type_derived_from_visibility() { + use crate::agents::AgentType; + + let planner = tool_ids_for_agent_type(&AgentType::Planner).unwrap(); + assert!(planner.iter().any(|id| id == "change_planner_mode")); + assert!(planner.iter().any(|id| id == "read_file")); + assert!(planner.iter().any(|id| id == "write_file")); + /* Agent-internal tools stay hidden from the planner. */ + assert!(!planner.iter().any(|id| id == "submit_batch_plan")); + assert!(!planner.iter().any(|id| id == "report_completion")); + + let chat = tool_ids_for_agent_type(&AgentType::ChatAgent).unwrap(); + /* Mutating tools remain visible to chat (approval-gated). */ + assert!(chat.iter().any(|id| id == "write_file")); + assert!(chat.iter().any(|id| id == "bash")); + /* Strategic and agent-internal tools are hidden from chat. */ + assert!(!chat.iter().any(|id| id == "change_planner_mode")); + assert!(!chat.iter().any(|id| id == "submit_batch_plan")); + assert!(!chat.iter().any(|id| id == "report_completion")); + + /* Curated agent types are unchanged. */ + let build = tool_ids_for_agent_type(&AgentType::BuildAgent).unwrap(); + assert!(build.iter().any(|id| id == "submit_batch_plan")); + assert_eq!(build.len(), 17); + } + + #[test] + fn test_context_visibility_serde_snake_case() { + let json = serde_json::to_string(&ContextVisibility::PlannerOnly).unwrap(); + assert_eq!(json, "\"planner_only\""); + let parsed: ContextVisibility = serde_json::from_str("\"agent_only\"").unwrap(); + assert_eq!(parsed, ContextVisibility::AgentOnly); + } + #[test] fn test_toolcategory_as_str() { assert_eq!(ToolCategory::Filesystem.as_str(), "filesystem");