diff --git a/src/crates/assembly/agent-content/prompts/agents/swarm_planner_agent.md b/src/crates/assembly/agent-content/prompts/agents/swarm_planner_agent.md index 9aadf51b8b..577fa91763 100644 --- a/src/crates/assembly/agent-content/prompts/agents/swarm_planner_agent.md +++ b/src/crates/assembly/agent-content/prompts/agents/swarm_planner_agent.md @@ -39,6 +39,8 @@ AgentSpawn accepts exactly these `agent_type` values: Track every agent id and background task id. Use `AgentWait` to collect results and `AgentSendInput` to route concrete follow-up instructions. +Use `AgentList` to inspect the latest status of your direct child agents. Use `AgentDelete` only when one or more direct children and their entire descendant subtrees are no longer needed; deletion is permanent and removes their sessions and pending results. + Use `SwarmReviewer` at risk-based checkpoints, especially for shared contracts, persistence, concurrency, cancellation, permissions, security boundaries, cross-module integration, or failed, skipped, incomplete, or uncertain verification. ## Review handling @@ -47,6 +49,7 @@ Use `SwarmReviewer` at risk-based checkpoints, especially for shared contracts, - If a review reports `needs_changes`, route each actionable finding to the responsible Worker. - Request another review only when the fixes materially change the reviewed contract or remaining risk warrants it. - Interrupt an agent only when its work is obsolete, unsafe, or irrecoverably blocked; set cascade deliberately when descendants should also stop. +- Use interruption when work should stop but the agent and session should remain available; use deletion only for permanent subtree removal. # Constraints diff --git a/src/crates/assembly/agent-content/prompts/agents/ultra_mode.md b/src/crates/assembly/agent-content/prompts/agents/ultra_mode.md index c7ce191ee0..00ed99d487 100644 --- a/src/crates/assembly/agent-content/prompts/agents/ultra_mode.md +++ b/src/crates/assembly/agent-content/prompts/agents/ultra_mode.md @@ -52,6 +52,8 @@ AgentSpawn accepts exactly these `agent_type` values: Track every returned agent id and background task id. Use `AgentWait` to collect results before declaring a package complete. +Use `AgentList` to inspect the latest status of your direct child agents. Use `AgentDelete` only when one or more direct children and their entire descendant subtrees are no longer needed; deletion is permanent and removes their sessions and pending results. + ## Review checkpoints Use `SwarmReviewer` at risk-based checkpoints. Review work affecting shared contracts, persistence, concurrency, cancellation, permissions, security boundaries, cross-module integration, or critical prerequisites. Also review work with failed, skipped, incomplete, or uncertain verification. @@ -65,6 +67,7 @@ Give each Reviewer the exact change set, originating Worker assignments, accepta - If a review reports `needs_changes`, route each concrete finding to the responsible Worker with `AgentSendInput`. - Request another review only when the fixes materially change the reviewed contract or remaining risk warrants it. - Interrupt an agent only when its work is obsolete, unsafe, or irrecoverably blocked; set cascade deliberately when descendants should also stop. +- Use interruption when work should stop but the agent and session should remain available; use deletion only for permanent subtree removal. # Decisions @@ -73,3 +76,39 @@ Ask the user a focused question through `AskUserQuestion` when a missing decisio # Completion Confirm that all required packages reached a terminal result. Reconcile Reviewer findings, identify unresolved risks, and answer the user directly with the completed outcome. + +# Tone and style +- Avoid emojis unless the user explicitly requests them. +- Keep responses concise. Use Github-flavored markdown when it improves readability. +- Communicate with the user in normal response text; use tools to perform work, not to narrate. + + +# File References +IMPORTANT: Whenever you mention a file path in normal prose that the user might want to open, make it a clickable markdown link: [text](url). + +**Link URL path**: +- For files inside the workspace, use the workspace-relative path: [filename.ts](src/filename.ts) +- For files outside the workspace, use the absolute path as the URL: [settings.json](/external/project/settings.json) + +**Line targets**: +- For a specific line, append `#L` to URL: [filename.ts:42](src/filename.ts#L42) +- For a line range, append `#L-L`: [filename.ts:42-51](src/filename.ts#L42-L51) + +**Link text and formatting**: +- Link text should be the bare filename, optionally with line numbers; do not include directory prefixes. +- Do not output bare paths as plain text in normal prose. Raw paths are appropriate inside commands, code/config snippets, or when the user explicitly asks for a copyable path. +- Do not wrap link text or the whole markdown link in backticks. + + +- Source file: [filename.ts](src/filename.ts) +- Specific line: [filename.ts:42](src/filename.ts#L42) +- External file line: [settings.json:12](/external/project/settings.json#L12) +- Generated report: [report.md](deep-research/report.md) + + +- Bare path: src/filename.ts +- Backticks in link text: [`filename.ts:42`](src/filename.ts#L42) +- Whole link wrapped in backticks: `[report.md](deep-research/report.md)` +- Full path in link text: [src/filename.ts](src/filename.ts) +- Absolute path as plain text: /external/project/deep-research/report.md + diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/modes/ultra.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/ultra.rs index ca8c546ef0..ddbdacf29d 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/modes/ultra.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/ultra.rs @@ -19,6 +19,8 @@ impl UltraMode { "AgentSpawn", "AgentSendInput", "AgentInterrupt", + "AgentList", + "AgentDelete", "AgentWait", "Read", "Grep", @@ -27,6 +29,7 @@ impl UltraMode { "ExecCommand", "WriteStdin", "ExecControl", + "ListModels", ] .into_iter() .map(str::to_string) diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/subagents/swarm.rs b/src/crates/assembly/core/src/agentic/agents/definitions/subagents/swarm.rs index ce366b0443..c079dcec15 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/subagents/swarm.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/subagents/swarm.rs @@ -38,6 +38,8 @@ impl Agent for SwarmPlannerAgent { "AgentSpawn", "AgentSendInput", "AgentInterrupt", + "AgentList", + "AgentDelete", "AgentWait", "Read", "Grep", diff --git a/src/crates/assembly/core/src/agentic/agents/registry/tests.rs b/src/crates/assembly/core/src/agentic/agents/registry/tests.rs index 08e8663ba5..f5cef171a8 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/tests.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/tests.rs @@ -367,6 +367,31 @@ fn every_builtin_mode_with_control_hub_can_also_schedule_with_cron() { } } +#[test] +fn agent_list_and_delete_are_exposed_only_to_swarm_planners() { + for spec in builtin_agent_specs() { + let agent = (spec.factory)(); + let has_list = agent.default_tools().iter().any(|tool| tool == "AgentList"); + let has_delete = agent + .default_tools() + .iter() + .any(|tool| tool == "AgentDelete"); + let should_have_controls = matches!(agent.id(), "Ultra" | "SwarmPlanner"); + assert_eq!( + has_list, + should_have_controls, + "unexpected AgentList exposure for {}", + agent.id() + ); + assert_eq!( + has_delete, + should_have_controls, + "unexpected AgentDelete exposure for {}", + agent.id() + ); + } +} + #[test] fn non_deep_review_builtin_subagents_default_to_primary() { for agent_type in [ diff --git a/src/crates/assembly/core/src/agentic/coordination/background_outcomes.rs b/src/crates/assembly/core/src/agentic/coordination/background_outcomes.rs index 44693e8cd2..d3deb5e35e 100644 --- a/src/crates/assembly/core/src/agentic/coordination/background_outcomes.rs +++ b/src/crates/assembly/core/src/agentic/coordination/background_outcomes.rs @@ -1,6 +1,6 @@ use super::coordination_store::{ BackgroundTaskRecord, BackgroundTaskRegistration, BackgroundTaskStatus, CoordinationStore, - RegisteredBackgroundTask, + DirectChildAgentRecord, RegisteredBackgroundTask, }; use super::coordinator::{SubagentResult, SubagentResultStatus}; use crate::agentic::session::SessionManager; @@ -61,15 +61,6 @@ pub(crate) enum BackgroundSubagentWaitMode { All, } -impl BackgroundSubagentWaitMode { - pub(crate) fn as_str(self) -> &'static str { - match self { - Self::Any => "any", - Self::All => "all", - } - } -} - #[derive(Debug, Clone)] pub(crate) struct BackgroundSubagentWaitResult { pub status: BackgroundSubagentWaitStatus, @@ -489,6 +480,27 @@ impl BackgroundSubagentOutcomeStore { .await } + pub(crate) async fn direct_child_agents( + &self, + parent_session_id: &str, + ) -> BitFunResult> { + self.reconcile_stale_running_tasks(parent_session_id) + .await?; + self.coordination_store + .direct_child_agents(parent_session_id) + .await + } + + pub(crate) async fn resolve_direct_child_agent_id( + &self, + parent_session_id: &str, + agent_id: &str, + ) -> BitFunResult { + self.coordination_store + .resolve_direct_child_agent_id(parent_session_id, agent_id) + .await + } + pub(crate) async fn reserve_swarm_child( &self, parent_session_id: &str, @@ -532,6 +544,15 @@ impl BackgroundSubagentOutcomeStore { .await } + pub(crate) async fn swarm_subtree_session_ids_postorder( + &self, + session_id: &str, + ) -> BitFunResult> { + self.coordination_store + .swarm_subtree_session_ids_postorder(session_id) + .await + } + pub(crate) async fn delete_session_references(&self, session_id: &str) -> BitFunResult<()> { let deleted_task_pks = self .coordination_store diff --git a/src/crates/assembly/core/src/agentic/coordination/coordination_store.rs b/src/crates/assembly/core/src/agentic/coordination/coordination_store.rs index ee57271e1c..4b7f503583 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordination_store.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordination_store.rs @@ -86,6 +86,13 @@ pub(crate) struct BackgroundTaskRecord { pub delivered_at_ms: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct DirectChildAgentRecord { + pub agent_id: String, + pub child_session_id: String, + pub status: BackgroundTaskStatus, +} + pub(crate) struct CoordinationStore { db_path: PathBuf, connection: OnceCell>>, @@ -176,6 +183,78 @@ impl CoordinationStore { .await } + pub(crate) async fn direct_child_agents( + &self, + parent_session_id: &str, + ) -> BitFunResult> { + let parent_session_id = parent_session_id.to_string(); + self.with_connection(move |connection| { + let mut statement = connection + .prepare( + r#" +WITH latest_tasks AS ( + SELECT agent_pk, status, + ROW_NUMBER() OVER (PARTITION BY agent_pk ORDER BY task_pk DESC) AS row_number + FROM background_tasks +) +SELECT agents.agent_id, agents.child_session_id, + COALESCE(latest_tasks.status, 'running') +FROM agents +JOIN swarm_nodes + ON swarm_nodes.session_id = agents.child_session_id + AND swarm_nodes.parent_session_id = agents.parent_session_id +LEFT JOIN latest_tasks + ON latest_tasks.agent_pk = agents.agent_pk + AND latest_tasks.row_number = 1 +WHERE agents.parent_session_id = ?1 + AND agents.state = 'active' +ORDER BY swarm_nodes.created_at_ms ASC, agents.agent_pk ASC + "#, + ) + .map_err(db_error)?; + let rows = statement + .query_map(params![parent_session_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + }) + .map_err(db_error)?; + rows.map(|row| { + let (agent_id, child_session_id, status) = row.map_err(db_error)?; + Ok(DirectChildAgentRecord { + agent_id, + child_session_id, + status: BackgroundTaskStatus::parse(&status)?, + }) + }) + .collect() + }) + .await + } + + pub(crate) async fn resolve_direct_child_agent_id( + &self, + parent_session_id: &str, + agent_id: &str, + ) -> BitFunResult { + let parent_session_id = parent_session_id.to_string(); + let agent_id = agent_id.to_string(); + self.with_connection(move |connection| { + connection + .query_row( + "SELECT agents.child_session_id FROM agents JOIN swarm_nodes ON swarm_nodes.session_id = agents.child_session_id AND swarm_nodes.parent_session_id = agents.parent_session_id WHERE agents.parent_session_id = ?1 AND agents.agent_id = ?2 AND agents.state = 'active'", + params![parent_session_id, agent_id], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(db_error)? + .ok_or_else(|| BitFunError::tool(format!("Direct child agent was not found: {agent_id}"))) + }) + .await + } + pub(crate) async fn reserve_swarm_child( &self, parent_session_id: &str, @@ -353,6 +432,34 @@ SELECT session_id FROM descendants .await } + pub(crate) async fn swarm_subtree_session_ids_postorder( + &self, + session_id: &str, + ) -> BitFunResult> { + let session_id = session_id.to_string(); + self.with_connection(move |connection| { + let mut statement = connection + .prepare( + r#" +WITH RECURSIVE subtree(session_id, depth) AS ( + SELECT session_id, depth FROM swarm_nodes WHERE session_id = ?1 + UNION ALL + SELECT child.session_id, child.depth + FROM swarm_nodes child + JOIN subtree parent ON child.parent_session_id = parent.session_id +) +SELECT session_id FROM subtree ORDER BY depth DESC, session_id ASC + "#, + ) + .map_err(db_error)?; + let rows = statement + .query_map(params![session_id], |row| row.get::<_, String>(0)) + .map_err(db_error)?; + rows.collect::>>().map_err(db_error) + }) + .await + } + pub(crate) async fn register_background_task( &self, registration: BackgroundTaskRegistration, @@ -644,6 +751,12 @@ WHERE task_pk = ?3 params![session_id], ) .map_err(db_error)?; + transaction + .execute( + "DELETE FROM swarm_nodes WHERE session_id = ?1", + params![session_id], + ) + .map_err(db_error)?; transaction .execute( "DELETE FROM swarm_trees WHERE root_session_id = ?1", @@ -1310,6 +1423,111 @@ mod tests { assert!(second_claim.is_empty()); } + #[tokio::test] + async fn direct_child_agents_use_latest_status_and_ignore_delivery() { + let (_root, store) = test_store(); + store + .reserve_swarm_child("root", "planner", "Ultra", "SwarmPlanner", 1) + .await + .expect("reserve planner"); + store + .reserve_swarm_child("planner", "worker", "SwarmPlanner", "SwarmWorker", 2) + .await + .expect("reserve nested worker"); + let first = store + .register_background_task(registration("root", "planner", "spawn-turn", None)) + .await + .expect("register first task"); + let latest = store + .register_background_task(registration("root", "planner", "follow-up-turn", None)) + .await + .expect("register latest task"); + store + .register_background_task(registration("planner", "worker", "nested-turn", None)) + .await + .expect("register nested task"); + store + .update_task_status(first.task_pk, BackgroundTaskStatus::Failed, None, None) + .await + .expect("fail first task"); + store + .update_task_status(latest.task_pk, BackgroundTaskStatus::Completed, None, None) + .await + .expect("complete latest task"); + store + .claim_terminal_tasks("root", &[latest.task_pk], "wait-turn") + .await + .expect("consume latest result"); + + let agents = store + .direct_child_agents("root") + .await + .expect("list direct children"); + assert_eq!(agents.len(), 1); + assert_eq!(agents[0].agent_id, first.agent_id); + assert_eq!(agents[0].child_session_id, "planner"); + assert_eq!(agents[0].status, BackgroundTaskStatus::Completed); + + store + .delete_session_references("planner") + .await + .expect("delete planner references"); + assert!(store + .direct_child_agents("root") + .await + .expect("list children after deletion") + .is_empty()); + store + .resolve_direct_child_agent_id("root", &first.agent_id) + .await + .expect_err("deleted agent id must no longer resolve"); + } + + #[tokio::test] + async fn direct_child_resolution_and_subtree_postorder_are_lineage_scoped() { + let (_root, store) = test_store(); + store + .reserve_swarm_child("root", "planner", "Ultra", "SwarmPlanner", 1) + .await + .expect("reserve planner"); + store + .reserve_swarm_child("planner", "worker", "SwarmPlanner", "SwarmWorker", 2) + .await + .expect("reserve worker"); + let planner = store + .register_background_task(registration("root", "planner", "planner-turn", None)) + .await + .expect("register planner"); + let worker = store + .register_background_task(registration( + "planner", + "worker", + "worker-turn", + Some("nested-worker"), + )) + .await + .expect("register worker"); + + assert_eq!( + store + .resolve_direct_child_agent_id("root", &planner.agent_id) + .await + .expect("resolve direct planner"), + "planner" + ); + store + .resolve_direct_child_agent_id("root", &worker.agent_id) + .await + .expect_err("a grandchild is not a direct child of root"); + assert_eq!( + store + .swarm_subtree_session_ids_postorder("planner") + .await + .expect("load subtree"), + ["worker", "planner"] + ); + } + #[tokio::test] async fn stale_running_tasks_can_only_be_reconciled_once() { let root = tempfile::tempdir().expect("coordination store temp directory"); diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index 4e1e0de390..41bb644f54 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -11496,6 +11496,150 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .await } + pub(crate) async fn direct_child_agents( + &self, + parent_session_id: &str, + ) -> BitFunResult> { + self.background_subagent_outcomes + .direct_child_agents(parent_session_id) + .await + } + + pub(crate) async fn delete_direct_child_agents( + &self, + parent_session_id: &str, + agent_ids: &[String], + ) -> BitFunResult { + let mut targets = Vec::with_capacity(agent_ids.len()); + for agent_id in agent_ids { + let target_session_id = self + .background_subagent_outcomes + .resolve_direct_child_agent_id(parent_session_id, agent_id) + .await?; + targets.push((agent_id.clone(), target_session_id)); + } + + let mut deleted_agents = 0usize; + for (agent_id, target_session_id) in targets { + deleted_agents += self + .delete_resolved_direct_child_agent( + parent_session_id, + &agent_id, + &target_session_id, + ) + .await?; + } + Ok(deleted_agents) + } + + async fn delete_resolved_direct_child_agent( + &self, + parent_session_id: &str, + agent_id: &str, + target_session_id: &str, + ) -> BitFunResult { + let subtree = self + .background_subagent_outcomes + .swarm_subtree_session_ids_postorder(target_session_id) + .await?; + if subtree.last().map(String::as_str) != Some(target_session_id) { + return Err(BitFunError::OutcomeUnknown(format!( + "Agent subtree could not be resolved completely: agent_id={agent_id}" + ))); + } + + let storage_path = self + .session_manager + .resolve_session_workspace_binding(parent_session_id) + .await + .map(|binding| binding.session_storage_dir()) + .ok_or_else(|| { + BitFunError::NotFound(format!( + "Parent session workspace not found: {parent_session_id}" + )) + })?; + for session_id in &subtree { + if self.session_manager.get_session(session_id).is_none() { + self.restore_internal_session_from_storage_path(&storage_path, session_id) + .await?; + } + } + + self.cancel_background_subagents_for_parent(parent_session_id, target_session_id, true) + .await?; + let deadline = Instant::now() + Duration::from_secs(30); + let mut maintenance_permits = Vec::new(); + if let Some(scheduler) = get_global_scheduler() { + for session_id in &subtree { + maintenance_permits.push( + scheduler + .begin_session_deletion( + session_id, + &storage_path, + deadline.saturating_duration_since(Instant::now()), + ) + .await?, + ); + } + } else { + for session_id in &subtree { + self.cancel_active_turn_for_session( + session_id, + deadline.saturating_duration_since(Instant::now()), + ) + .await?; + self.ensure_session_execution_drained( + session_id, + deadline.saturating_duration_since(Instant::now()), + ) + .await?; + } + } + + for session_id in &subtree { + self.delete_agent_session_by_id(session_id).await?; + } + drop(maintenance_permits); + Ok(subtree.len()) + } + + async fn delete_agent_session_by_id(&self, session_id: &str) -> BitFunResult<()> { + let session = self + .session_manager + .get_session(session_id) + .ok_or_else(|| BitFunError::NotFound(format!("Session not found: {session_id}")))?; + let workspace_path = session.config.workspace_path.clone().map(PathBuf::from); + let is_remote_workspace = Self::session_hooks_are_remote(&session).await; + let model = session.config.model_id.clone().unwrap_or_default(); + if let Some(workspace_path) = workspace_path.as_deref() { + native_hooks::dispatch_session_end( + NativeHookSessionFacts { + session_id, + turn_id: None, + workspace_root: Some(workspace_path), + is_remote_workspace, + model: &model, + bypass_permissions: false, + }, + "other", + ) + .await; + } else { + native_hooks::clear_session_hook_state(session_id); + } + self.session_manager + .delete_session_by_id(session_id) + .await?; + self.background_subagent_outcomes + .delete_session_references(session_id) + .await?; + self.emit_event(AgenticEvent::SessionDeleted { + session_id: session_id.to_string(), + }) + .await; + Ok(()) + } + pub(crate) async fn swarm_depth_for_session( &self, session_id: &str, diff --git a/src/crates/assembly/core/src/agentic/coordination/mod.rs b/src/crates/assembly/core/src/agentic/coordination/mod.rs index aaba17c2b1..2390a14cba 100644 --- a/src/crates/assembly/core/src/agentic/coordination/mod.rs +++ b/src/crates/assembly/core/src/agentic/coordination/mod.rs @@ -19,6 +19,7 @@ pub(crate) use background_outcomes::{ BackgroundSubagentOutcome, BackgroundSubagentOutcomeStore, BackgroundSubagentWaitMode, BackgroundSubagentWaitResult, }; +pub(crate) use coordination_store::DirectChildAgentRecord; pub use coordinator::get_global_coordinator; pub use scheduler::get_global_scheduler; diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/agent_delete_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/agent_delete_tool.rs new file mode 100644 index 0000000000..c8cb5e808c --- /dev/null +++ b/src/crates/assembly/core/src/agentic/tools/implementations/agent_delete_tool.rs @@ -0,0 +1,254 @@ +use crate::agentic::agents::is_swarm_planner_agent_type; +use crate::agentic::coordination::get_global_coordinator; +use crate::agentic::tools::framework::{ + PermissionIntent, Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, +}; +use crate::util::errors::{BitFunError, BitFunResult}; +use async_trait::async_trait; +use serde_json::{json, Value}; +use std::collections::HashSet; + +pub struct AgentDeleteTool; + +impl Default for AgentDeleteTool { + fn default() -> Self { + Self::new() + } +} + +impl AgentDeleteTool { + pub fn new() -> Self { + Self + } + + fn parse_agent_ids(input: &Value) -> BitFunResult> { + let object = input + .as_object() + .ok_or_else(|| BitFunError::tool("AgentDelete input must be an object".to_string()))?; + if let Some(field) = object.keys().find(|field| field.as_str() != "agent_ids") { + return Err(BitFunError::tool(format!( + "AgentDelete does not accept field '{field}'" + ))); + } + let values = match object.get("agent_ids") { + Some(value @ Value::String(_)) => vec![value], + Some(Value::Array(values)) => values.iter().collect(), + Some(_) => { + return Err(BitFunError::tool( + "agent_ids must be a string or an array of strings".to_string(), + )); + } + None => { + return Err(BitFunError::tool( + "agent_ids is required for AgentDelete".to_string(), + )); + } + }; + + let mut seen = HashSet::new(); + let mut agent_ids = Vec::new(); + for value in values { + let agent_id = value + .as_str() + .map(str::trim) + .filter(|agent_id| !agent_id.is_empty()) + .ok_or_else(|| { + BitFunError::tool("agent_ids must contain only non-empty strings".to_string()) + })?; + if seen.insert(agent_id.to_string()) { + agent_ids.push(agent_id.to_string()); + } + } + if agent_ids.is_empty() { + return Err(BitFunError::tool( + "agent_ids must contain at least one agent ID".to_string(), + )); + } + Ok(agent_ids) + } + + fn ensure_context_allowed(context: &ToolUseContext) -> BitFunResult<()> { + let agent_type = context + .agent_type + .as_deref() + .ok_or_else(|| BitFunError::tool("agent_type is required in context".to_string()))?; + if !is_swarm_planner_agent_type(agent_type) { + return Err(BitFunError::tool( + "AgentDelete is available only to Ultra and SwarmPlanner".to_string(), + )); + } + Ok(()) + } +} + +#[async_trait] +impl Tool for AgentDeleteTool { + fn name(&self) -> &str { + "AgentDelete" + } + + fn manages_own_execution_timeout(&self) -> bool { + true + } + + async fn description(&self) -> BitFunResult { + Ok("Permanently delete one or more direct child agents and their entire descendant subtrees. Active work is cancelled before the sessions and pending results are removed.".to_string()) + } + + fn short_description(&self) -> String { + "Permanently delete direct child agent subtrees.".to_string() + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "agent_ids": { + "type": "array", + "items": { "type": "string" }, + "description": "Direct child agent IDs to delete." + } + }, + "required": ["agent_ids"], + "additionalProperties": false + }) + } + + fn is_readonly(&self) -> bool { + false + } + + fn permission_intents( + &self, + input: &Value, + _context: &ToolUseContext, + ) -> BitFunResult> { + let resources = Self::parse_agent_ids(input)? + .into_iter() + .map(|agent_id| format!("delete:{agent_id}")) + .collect(); + Ok(vec![PermissionIntent::new("task", resources)]) + } + + fn render_tool_use_message(&self, input: &Value, _options: &ToolRenderOptions) -> String { + Self::parse_agent_ids(input) + .map(|agent_ids| format!("Deleting agent subtrees: {}", agent_ids.join(", "))) + .unwrap_or_else(|_| "Deleting agent subtrees".to_string()) + } + + async fn validate_input( + &self, + input: &Value, + context: Option<&ToolUseContext>, + ) -> ValidationResult { + let result = Self::parse_agent_ids(input).and_then(|_| { + if let Some(context) = context { + Self::ensure_context_allowed(context)?; + } + Ok(()) + }); + match result { + Ok(()) => ValidationResult::default(), + Err(error) => ValidationResult { + result: false, + message: Some(error.to_string()), + error_code: None, + meta: None, + }, + } + } + + async fn call_impl( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + Self::ensure_context_allowed(context)?; + let agent_ids = Self::parse_agent_ids(input)?; + let session_id = context + .session_id + .as_deref() + .ok_or_else(|| BitFunError::tool("session_id is required in context".to_string()))?; + let coordinator = get_global_coordinator() + .ok_or_else(|| BitFunError::tool("coordinator not initialized".to_string()))?; + let deleted_agents = coordinator + .delete_direct_child_agents(session_id, &agent_ids) + .await?; + Ok(vec![ToolResult::Result { + data: json!({ + "agent_ids": agent_ids, + "status": "deleted", + "deleted_agents": deleted_agents, + }), + result_for_assistant: Some(format!( + "Permanently deleted the selected agent subtrees ({}) containing {deleted_agents} agent session(s).", + agent_ids.join(", ") + )), + image_attachments: None, + }]) + } +} + +#[cfg(test)] +mod tests { + use super::AgentDeleteTool; + use crate::agentic::tools::framework::{Tool, ToolUseContext}; + use crate::agentic::tools::ToolRuntimeRestrictions; + use std::collections::HashMap; + + fn context(agent_type: &str) -> ToolUseContext { + ToolUseContext { + tool_call_id: Some("tool-call".to_string()), + agent_type: Some(agent_type.to_string()), + session_id: Some("session".to_string()), + dialog_turn_id: Some("turn".to_string()), + workspace: None, + loaded_deferred_tool_specs: Vec::new(), + primary_model_facts: tool_runtime::context::PrimaryModelFacts::default(), + custom_data: HashMap::new(), + computer_use_host: None, + runtime_tool_restrictions: ToolRuntimeRestrictions::default(), + runtime_handles: bitfun_runtime_ports::ToolRuntimeHandles::default(), + } + } + + #[test] + fn permission_is_scoped_to_all_deleted_agents() { + let intents = AgentDeleteTool::new() + .permission_intents( + &serde_json::json!({ "agent_ids": ["a2", "a3"] }), + &context("Ultra"), + ) + .expect("permission intent"); + assert_eq!(intents.len(), 1); + assert_eq!(intents[0].action, "task"); + assert_eq!(intents[0].resources, ["delete:a2", "delete:a3"]); + } + + #[test] + fn parser_tolerates_a_string_and_deduplicates_arrays() { + assert_eq!( + AgentDeleteTool::parse_agent_ids(&serde_json::json!({ "agent_ids": " a2 " })) + .expect("single string"), + ["a2"] + ); + assert_eq!( + AgentDeleteTool::parse_agent_ids( + &serde_json::json!({ "agent_ids": ["a2", " a2 ", "a3"] }), + ) + .expect("deduplicated array"), + ["a2", "a3"] + ); + } + + #[tokio::test] + async fn validation_rejects_non_planner_contexts() { + let validation = AgentDeleteTool::new() + .validate_input( + &serde_json::json!({ "agent_ids": ["a1"] }), + Some(&context("SwarmWorker")), + ) + .await; + assert!(!validation.result); + } +} diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/agent_list_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/agent_list_tool.rs new file mode 100644 index 0000000000..ba8b7d801f --- /dev/null +++ b/src/crates/assembly/core/src/agentic/tools/implementations/agent_list_tool.rs @@ -0,0 +1,188 @@ +use crate::agentic::agents::is_swarm_planner_agent_type; +use crate::agentic::coordination::get_global_coordinator; +use crate::agentic::tools::framework::{ + PermissionIntent, Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, +}; +use crate::util::errors::{BitFunError, BitFunResult}; +use async_trait::async_trait; +use serde_json::{json, Value}; + +pub struct AgentListTool; + +impl Default for AgentListTool { + fn default() -> Self { + Self::new() + } +} + +impl AgentListTool { + pub fn new() -> Self { + Self + } + + fn validate_request(input: &Value, context: Option<&ToolUseContext>) -> BitFunResult<()> { + let object = input + .as_object() + .ok_or_else(|| BitFunError::tool("AgentList input must be an object".to_string()))?; + if let Some(field) = object.keys().next() { + return Err(BitFunError::tool(format!( + "AgentList does not accept field '{field}'" + ))); + } + if let Some(context) = context { + let agent_type = context.agent_type.as_deref().ok_or_else(|| { + BitFunError::tool("agent_type is required in context".to_string()) + })?; + if !is_swarm_planner_agent_type(agent_type) { + return Err(BitFunError::tool( + "AgentList is available only to Ultra and SwarmPlanner".to_string(), + )); + } + } + Ok(()) + } +} + +#[async_trait] +impl Tool for AgentListTool { + fn name(&self) -> &str { + "AgentList" + } + + async fn description(&self) -> BitFunResult { + Ok("List direct child agents and their latest status.".to_string()) + } + + fn short_description(&self) -> String { + "List direct child agents and their status.".to_string() + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }) + } + + fn is_readonly(&self) -> bool { + true + } + + fn permission_intents( + &self, + _input: &Value, + _context: &ToolUseContext, + ) -> BitFunResult> { + Ok(Vec::new()) + } + + fn render_tool_use_message(&self, _input: &Value, _options: &ToolRenderOptions) -> String { + "Listing child agents".to_string() + } + + async fn validate_input( + &self, + input: &Value, + context: Option<&ToolUseContext>, + ) -> ValidationResult { + match Self::validate_request(input, context) { + Ok(()) => ValidationResult::default(), + Err(error) => ValidationResult { + result: false, + message: Some(error.to_string()), + error_code: None, + meta: None, + }, + } + } + + async fn call_impl( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + Self::validate_request(input, Some(context))?; + let session_id = context + .session_id + .as_deref() + .ok_or_else(|| BitFunError::tool("session_id is required in context".to_string()))?; + let coordinator = get_global_coordinator() + .ok_or_else(|| BitFunError::tool("coordinator not initialized".to_string()))?; + let agents = coordinator + .direct_child_agents(session_id) + .await? + .into_iter() + .map(|agent| { + json!({ + "agent_id": agent.agent_id, + "status": agent.status.as_str(), + }) + }) + .collect::>(); + let count = agents.len(); + Ok(vec![ToolResult::Result { + data: json!({ "agents": agents }), + result_for_assistant: Some(format!("Found {count} direct child agent(s).")), + image_attachments: None, + }]) + } +} + +#[cfg(test)] +mod tests { + use super::AgentListTool; + use crate::agentic::tools::framework::{Tool, ToolUseContext}; + use crate::agentic::tools::ToolRuntimeRestrictions; + use std::collections::HashMap; + + fn context(agent_type: &str) -> ToolUseContext { + ToolUseContext { + tool_call_id: Some("tool-call".to_string()), + agent_type: Some(agent_type.to_string()), + session_id: Some("session".to_string()), + dialog_turn_id: Some("turn".to_string()), + workspace: None, + loaded_deferred_tool_specs: Vec::new(), + primary_model_facts: tool_runtime::context::PrimaryModelFacts::default(), + custom_data: HashMap::new(), + computer_use_host: None, + runtime_tool_restrictions: ToolRuntimeRestrictions::default(), + runtime_handles: bitfun_runtime_ports::ToolRuntimeHandles::default(), + } + } + + #[test] + fn schema_is_a_strict_empty_object() { + assert_eq!( + AgentListTool::new().input_schema(), + serde_json::json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }) + ); + } + + #[tokio::test] + async fn validation_accepts_only_swarm_planners() { + assert!( + AgentListTool::new() + .validate_input(&serde_json::json!({}), Some(&context("Ultra"))) + .await + .result + ); + assert!( + AgentListTool::new() + .validate_input(&serde_json::json!({}), Some(&context("SwarmPlanner")),) + .await + .result + ); + assert!( + !AgentListTool::new() + .validate_input(&serde_json::json!({}), Some(&context("SwarmWorker"))) + .await + .result + ); + } +} diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/agent_wait_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/agent_wait_tool.rs index fb32fb1f8d..1fa32177ae 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/agent_wait_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/agent_wait_tool.rs @@ -11,16 +11,16 @@ use serde_json::{json, Value}; use std::collections::HashSet; use tokio::time::Duration; -const DEFAULT_TIMEOUT_MS: u64 = 10 * 60 * 1_000; -const MAX_TIMEOUT_MS: u64 = 60 * 60 * 1_000; +const MIN_TIMEOUT_SECONDS: u64 = 30 * 60; +const DEFAULT_TIMEOUT_SECONDS: u64 = MIN_TIMEOUT_SECONDS; +const MAX_TIMEOUT_SECONDS: u64 = 60 * 60; pub struct AgentWaitTool; #[derive(Debug, PartialEq, Eq)] struct AgentWaitRequest { bg_task_ids: Vec, - wait_mode: BackgroundSubagentWaitMode, - timeout_ms: u64, + timeout_seconds: u64, } impl Default for AgentWaitTool { @@ -71,39 +71,15 @@ impl AgentWaitTool { Ok(AgentWaitRequest { bg_task_ids, - wait_mode: Self::parse_wait_mode(object.get("wait_mode"))?, - timeout_ms: Self::parse_timeout_ms(object.get("timeout_ms")), + timeout_seconds: Self::parse_timeout_seconds(object.get("timeout_seconds")), }) } - fn parse_wait_mode(wait_mode: Option<&Value>) -> BitFunResult { - let wait_mode = match wait_mode { - None => BackgroundSubagentWaitMode::All, - Some(Value::String(value)) => match value.trim() { - "any" => BackgroundSubagentWaitMode::Any, - "all" => BackgroundSubagentWaitMode::All, - value => { - return Err(BitFunError::tool(format!( - "wait_mode must be \"any\" or \"all\"; got: {}", - value - ))); - } - }, - Some(_) => { - return Err(BitFunError::tool( - "wait_mode must be \"any\" or \"all\"".to_string(), - )); - } - }; - Ok(wait_mode) - } - - fn parse_timeout_ms(timeout_ms: Option<&Value>) -> u64 { - timeout_ms + fn parse_timeout_seconds(timeout_seconds: Option<&Value>) -> u64 { + timeout_seconds .and_then(Value::as_u64) - .filter(|timeout_ms| *timeout_ms > 0) - .unwrap_or(DEFAULT_TIMEOUT_MS) - .min(MAX_TIMEOUT_MS) + .unwrap_or(DEFAULT_TIMEOUT_SECONDS) + .clamp(MIN_TIMEOUT_SECONDS, MAX_TIMEOUT_SECONDS) } fn outcome_json(outcome: &BackgroundSubagentOutcome) -> Value { @@ -164,13 +140,11 @@ impl Tool for AgentWaitTool { async fn description(&self) -> BitFunResult { Ok("Wait for background agent results. -Set wait_mode to `any` to return after any selected task completes, or `all` to wait for every selected task. -Provide bg_task_ids when known; omit it or pass [] to select all unconsumed background tasks. -The selected task set is fixed when the call starts. wait_mode defaults to `all`; the tool also returns when `timeout_ms` has elapsed.".to_string()) +Wait for every selected task to complete. The tool also returns when `timeout_seconds` has elapsed.".to_string()) } fn short_description(&self) -> String { - "Wait for selected background subagent results.".to_string() + "Wait for selected background agent results.".to_string() } fn input_schema(&self) -> Value { @@ -180,19 +154,14 @@ The selected task set is fixed when the call starts. wait_mode defaults to `all` "bg_task_ids": { "type": "array", "items": { "type": "string" }, - "description": "Optional background task IDs. Omit this field or pass [] to select all unconsumed background agent results." + "description": "Background task IDs whose results should be collected." }, - "wait_mode": { - "type": "string", - "enum": ["any", "all"], - "default": "all", - "description": "Defaults to `all`." - }, - "timeout_ms": { + "timeout_seconds": { "type": "integer", - "description": "Maximum time to wait in milliseconds. Defaults to ten minutes." + "description": "Maximum time to wait in seconds, with a minimum of 30 minutes (default) and a maximum of 1 hour." } }, + "required": ["bg_task_ids"], "additionalProperties": false }) } @@ -257,15 +226,14 @@ The selected task set is fixed when the call starts. wait_mode defaults to `all` .wait_for_background_subagent_outcomes( session_id, &request.bg_task_ids, - request.wait_mode, - Duration::from_millis(request.timeout_ms), + BackgroundSubagentWaitMode::All, + Duration::from_secs(request.timeout_seconds), dialog_turn_id, context.cancellation_token(), ) .await?; let data = json!({ "status": result.status.as_str(), - "wait_mode": request.wait_mode.as_str(), "results": result.outcomes.iter().map(Self::outcome_json).collect::>(), "pending_bg_task_ids": result.pending_bg_task_ids, }); @@ -279,56 +247,20 @@ The selected task set is fixed when the call starts. wait_mode defaults to `all` #[cfg(test)] mod tests { - use super::{AgentWaitTool, DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS}; - use crate::agentic::coordination::BackgroundSubagentWaitMode; + use super::{AgentWaitTool, DEFAULT_TIMEOUT_SECONDS, MAX_TIMEOUT_SECONDS, MIN_TIMEOUT_SECONDS}; use crate::agentic::tools::framework::Tool; #[test] - fn schema_exposes_only_parent_scoped_background_task_ids() { - let schema = AgentWaitTool::new().input_schema(); - - assert_eq!(schema["properties"]["bg_task_ids"]["type"], "array"); - assert!(schema["properties"].get("background_task_ids").is_none()); - } - - #[test] - fn empty_input_uses_the_default_timeout_and_session_selector() { + fn missing_or_empty_task_ids_are_tolerated_by_the_parser() { let request = AgentWaitTool::parse_request(&serde_json::json!({})).expect("valid request"); assert!(request.bg_task_ids.is_empty()); - assert_eq!(request.wait_mode, BackgroundSubagentWaitMode::All); - assert_eq!(request.timeout_ms, DEFAULT_TIMEOUT_MS); - } - - #[test] - fn explicit_wait_mode_applies_to_session_and_exact_task_selectors() { - let any = AgentWaitTool::parse_request(&serde_json::json!({ - "wait_mode": "any" - })) - .expect("any wait mode must be valid"); - assert!(any.bg_task_ids.is_empty()); - assert_eq!(any.wait_mode, BackgroundSubagentWaitMode::Any); - - let all = AgentWaitTool::parse_request(&serde_json::json!({ - "wait_mode": "all" - })) - .expect("all wait mode must be valid"); - assert!(all.bg_task_ids.is_empty()); - assert_eq!(all.wait_mode, BackgroundSubagentWaitMode::All); + assert_eq!(request.timeout_seconds, DEFAULT_TIMEOUT_SECONDS); let empty = AgentWaitTool::parse_request(&serde_json::json!({ - "bg_task_ids": [], - "wait_mode": "any" + "bg_task_ids": [] })) .expect("an empty selector must be valid"); - assert_eq!(empty.wait_mode, BackgroundSubagentWaitMode::Any); - - let exact = AgentWaitTool::parse_request(&serde_json::json!({ - "bg_task_ids": ["bg1", "bg2"], - "wait_mode": "any" - })) - .expect("exact task IDs must be valid"); - assert_eq!(exact.wait_mode, BackgroundSubagentWaitMode::Any); - assert_eq!(exact.bg_task_ids, ["bg1", "bg2"]); + assert!(empty.bg_task_ids.is_empty()); } #[test] @@ -338,7 +270,6 @@ mod tests { })) .expect("a single task ID string must be accepted"); assert_eq!(request.bg_task_ids, ["bg1"]); - assert_eq!(request.wait_mode, BackgroundSubagentWaitMode::All); } #[test] @@ -369,24 +300,24 @@ mod tests { } #[test] - fn timeout_and_unknown_parameters_are_tolerated() { + fn timeout_uses_seconds_and_is_clamped_to_supported_bounds() { let defaulted = AgentWaitTool::parse_request(&serde_json::json!({ - "timeout_ms": "invalid", + "timeout_seconds": "invalid", "unused": true })) .expect("invalid timeout and unknown parameters must be tolerated"); - assert_eq!(defaulted.timeout_ms, DEFAULT_TIMEOUT_MS); + assert_eq!(defaulted.timeout_seconds, DEFAULT_TIMEOUT_SECONDS); let capped = AgentWaitTool::parse_request(&serde_json::json!({ - "timeout_ms": MAX_TIMEOUT_MS + 1 + "timeout_seconds": MAX_TIMEOUT_SECONDS + 1 })) .expect("large timeout must be capped"); - assert_eq!(capped.timeout_ms, MAX_TIMEOUT_MS); + assert_eq!(capped.timeout_seconds, MAX_TIMEOUT_SECONDS); - let zero = AgentWaitTool::parse_request(&serde_json::json!({ - "timeout_ms": 0 + let raised = AgentWaitTool::parse_request(&serde_json::json!({ + "timeout_seconds": MIN_TIMEOUT_SECONDS - 1 })) - .expect("zero timeout must use the default"); - assert_eq!(zero.timeout_ms, DEFAULT_TIMEOUT_MS); + .expect("short timeout must be raised to the minimum"); + assert_eq!(raised.timeout_seconds, MIN_TIMEOUT_SECONDS); } } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs b/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs index 56da8affd4..78f2dc321c 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs @@ -1,5 +1,7 @@ //! Tool implementation module +pub mod agent_delete_tool; +pub mod agent_list_tool; pub mod agent_wait_tool; #[cfg(feature = "tools-image-analysis")] pub mod analyze_image_tool; @@ -74,6 +76,8 @@ pub mod worktree_tool; #[deprecated(note = "GetToolSpecTool is owned by the product tool runtime boundary")] pub use crate::agentic::tools::product_runtime::GetToolSpecTool; +pub use agent_delete_tool::AgentDeleteTool; +pub use agent_list_tool::AgentListTool; pub use agent_wait_tool::AgentWaitTool; #[cfg(feature = "tools-image-analysis")] pub use analyze_image_tool::AnalyzeImageTool; diff --git a/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs b/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs index 77e36278e2..9e799b11cb 100644 --- a/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs +++ b/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs @@ -53,6 +53,8 @@ impl StaticToolProviderFactory for ProductConcreteToolFactory { "AgentSpawn" => Some(Arc::new(AgentSpawnTool::new())), "AgentSendInput" => Some(Arc::new(AgentSendInputTool::new())), "AgentInterrupt" => Some(Arc::new(AgentInterruptTool::new())), + "AgentList" => Some(Arc::new(AgentListTool::new())), + "AgentDelete" => Some(Arc::new(AgentDeleteTool::new())), "AgentWait" => Some(Arc::new(AgentWaitTool::new())), "LaunchReviewAgent" => Some(Arc::new(LaunchReviewAgentTool::new())), "Skill" => Some(Arc::new(SkillTool::new())), diff --git a/src/crates/assembly/core/src/agentic/tools/registry.rs b/src/crates/assembly/core/src/agentic/tools/registry.rs index 08511d3b47..66e72ff5dd 100644 --- a/src/crates/assembly/core/src/agentic/tools/registry.rs +++ b/src/crates/assembly/core/src/agentic/tools/registry.rs @@ -573,6 +573,8 @@ mod tests { "AgentSpawn", "AgentSendInput", "AgentInterrupt", + "AgentList", + "AgentDelete", "AgentWait", "LaunchReviewAgent", "Skill", @@ -829,6 +831,7 @@ mod tests { "Grep", "GetTime", "ListModels", + "AgentList", "Skill", "AskUserQuestion", "TodoWrite", diff --git a/src/crates/execution/tool-provider-groups/src/lib.rs b/src/crates/execution/tool-provider-groups/src/lib.rs index 63adbeb400..60558dcf6c 100644 --- a/src/crates/execution/tool-provider-groups/src/lib.rs +++ b/src/crates/execution/tool-provider-groups/src/lib.rs @@ -101,12 +101,11 @@ pub fn tool_feature_group(tool_name: &str) -> Option { "CreateCanvas" | "ReadCanvas" | "UpdateCanvas" | "PatchCanvas" => { Some(ToolPackFeatureGroup::Canvas) } - "Task" | "AgentSpawn" | "AgentSendInput" | "AgentInterrupt" | "AgentWait" - | "LaunchReviewAgent" | "Skill" | "AskUserQuestion" | "TodoWrite" | "get_goal" - | "create_goal" | "update_goal" | "CreatePlan" | "submit_code_review" | "GetToolSpec" - | "CallDeferredTool" | "SessionControl" | "SessionMessage" | "SessionHistory" | "Cron" => { - Some(ToolPackFeatureGroup::AgentControl) - } + "Task" | "AgentSpawn" | "AgentSendInput" | "AgentInterrupt" | "AgentList" + | "AgentDelete" | "AgentWait" | "LaunchReviewAgent" | "Skill" | "AskUserQuestion" + | "TodoWrite" | "get_goal" | "create_goal" | "update_goal" | "CreatePlan" + | "submit_code_review" | "GetToolSpec" | "CallDeferredTool" | "SessionControl" + | "SessionMessage" | "SessionHistory" | "Cron" => Some(ToolPackFeatureGroup::AgentControl), _ => None, } } @@ -190,6 +189,8 @@ const PRODUCT_TOOL_PROVIDER_GROUP_PLAN: &[ToolProviderGroupPlan] = &[ "AgentSpawn", "AgentSendInput", "AgentInterrupt", + "AgentList", + "AgentDelete", "AgentWait", "LaunchReviewAgent", "Skill", @@ -482,6 +483,8 @@ mod tests { "AgentSpawn", "AgentSendInput", "AgentInterrupt", + "AgentList", + "AgentDelete", "AgentWait", "LaunchReviewAgent", "Skill",