Uploads, Sync, Changetracking, Pods throttle

This commit is contained in:
Alex Emmet 2026-07-06 00:43:55 +02:00
commit ae6375a1d9
2 changed files with 327 additions and 54 deletions

View file

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

View file

@ -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",
@ -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,14 +638,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: $timeout,
deprecated: false,
deprecation_message: String::new(),
execution_context: ExecutionContext::default(),
context_visibility: ContextVisibility::default(),
}
};
}
@ -595,9 +659,13 @@ pub trait ToolPlugin: Send + Sync {
fn schema(&self) -> serde_json::Value;
fn execute(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
/* 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<ToolExample>,
}
/* 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<ToolDefinition> {
vec![
let mut tools = vec![
// ========================
// Filesystem Tools
// ========================
@ -1398,14 +1484,14 @@ pub fn tool_definitions() -> Vec<ToolDefinition> {
),
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<ToolDefinition> {
"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<Vec<&'static str>> {
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<Vec<String>> {
let curated = |ids: &[&str]| ids.iter().map(|s| s.to_string()).collect::<Vec<String>>();
let by_visibility = |allowed: &[ContextVisibility]| {
tool_definitions()
.into_iter()
.filter(|t| allowed.contains(&t.context_visibility))
.map(|t| t.id)
.collect::<Vec<String>>()
};
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");