From 2cb55fc2a5e05d91086394ca4640baff02e1e937 Mon Sep 17 00:00:00 2001 From: Jorben Date: Sat, 6 Jun 2026 22:31:52 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(subagent):=20=E2=9C=A8=20add=20recursi?= =?UTF-8?q?ve=20delegation=20with=20configurable=20depth=20limits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement multi-level subagent delegation where agents can delegate tasks to other subagents, with safety bounds: - Add `can_delegate` and `max_delegation_depth` fields to custom subagent model, DTO, and persistence layer - Introduce `HelperDelegationContext` for recursive delegation in the orchestrator, with `run_helper_boxed` to break the infinite opaque-type recursion cycle - Enforce global max depth (5) and per-agent max delegation depth - Built-in explore cannot delegate; review can delegate up to depth 3 - Refactor `resolve_custom_subagent_profile` into reusable free functions for pool-based access from both main session and helpers - Support `agent_parallel` delegation from delegating helpers (sequential at deeper levels to bound resource usage) - Add UI controls for delegation settings in agents settings panel - Add i18n strings for both en and zh-CN locales --- ...00000_custom_subagent_delegation_depth.sql | 7 + src-tauri/src/core/agent_session_execution.rs | 71 ++- src-tauri/src/core/agent_session_tests.rs | 12 + src-tauri/src/core/agent_session_tools.rs | 83 ++++ src-tauri/src/core/subagent/orchestrator.rs | 430 +++++++++++++++++- .../core/subagent/runtime_orchestration.rs | 199 +++++++- src-tauri/src/model/subagent.rs | 8 + .../persistence/repo/custom_subagent_repo.rs | 33 +- src-tauri/tests/custom_subagent.rs | 81 ++++ src/i18n/locales/en.ts | 6 + src/i18n/locales/zh-CN.ts | 6 + src/modules/settings-center/model/types.ts | 2 + .../ui/agents-settings-panel.tsx | 46 ++ src/services/bridge/subagent-commands.ts | 2 + 14 files changed, 940 insertions(+), 46 deletions(-) create mode 100644 src-tauri/migrations/20260606000000_custom_subagent_delegation_depth.sql diff --git a/src-tauri/migrations/20260606000000_custom_subagent_delegation_depth.sql b/src-tauri/migrations/20260606000000_custom_subagent_delegation_depth.sql new file mode 100644 index 00000000..276e0569 --- /dev/null +++ b/src-tauri/migrations/20260606000000_custom_subagent_delegation_depth.sql @@ -0,0 +1,7 @@ +ALTER TABLE custom_subagents +ADD COLUMN can_delegate INTEGER NOT NULL DEFAULT 0 +CHECK (can_delegate IN (0, 1)); + +ALTER TABLE custom_subagents +ADD COLUMN max_delegation_depth INTEGER NOT NULL DEFAULT 3 +CHECK (max_delegation_depth >= 1 AND max_delegation_depth <= 5); diff --git a/src-tauri/src/core/agent_session_execution.rs b/src-tauri/src/core/agent_session_execution.rs index 65266f63..404eeb0e 100644 --- a/src-tauri/src/core/agent_session_execution.rs +++ b/src-tauri/src/core/agent_session_execution.rs @@ -920,6 +920,21 @@ impl AgentSession { format!("No helper profile resolved for tool '{}'", tool.tool_name()) })?; + // The main agent is depth 1, so its direct delegates run at depth 2. + // Reject targets whose configured max delegation depth cannot accommodate + // being delegated to at depth 2 (mirrors the recursive subagent path's + // enforcement in HelperDelegationContext::resolve_delegation_sync). + const MAIN_AGENT_CHILD_DEPTH: u32 = 2; + if let Some(profile) = resolved_profile.as_ref() { + let max_depth = profile.max_delegation_depth(); + if MAIN_AGENT_CHILD_DEPTH > max_depth { + return Err(format!( + "{} cannot be delegated at depth {MAIN_AGENT_CHILD_DEPTH} (its max delegation depth is {max_depth})", + tool.tool_name() + )); + } + } + Ok(ResolvedHelperDelegate { agent_name: tool.tool_name(), tool, @@ -935,6 +950,12 @@ impl AgentSession { delegate: &ResolvedHelperDelegate, parent_tool_call_id: &str, ) -> Result { + // Custom subagents accessible from the active profile, so a delegated + // helper that is allowed to delegate further can inject and resolve + // `agent_{slug}` delegation tools without re-reading the active profile. + let custom_delegation_targets = + crate::core::agent_session_tools::list_custom_delegation_targets_from_pool(&self.pool) + .await; self.helper_orchestrator .run_helper(HelperRunRequest { run_id: self.spec.run_id.clone(), @@ -950,6 +971,10 @@ impl AgentSession { event_tx: self.event_tx.clone(), session_abort_signal: self.abort_signal.clone(), thinking_level: self.spec.model_plan.thinking_level, + // The main agent is depth 1; its direct delegates are depth 2. + delegation_depth: 2, + model_plan: self.spec.model_plan.clone(), + custom_delegation_targets, }) .await } @@ -1005,44 +1030,10 @@ impl AgentSession { /// Validates both that the subagent is enabled and that it is accessible from the /// active agent profile via the `profile_subagent_access` table. async fn resolve_custom_subagent_profile(&self, slug: &str) -> Option { - use crate::persistence::repo::{custom_subagent_repo, settings_repo}; - - let record = custom_subagent_repo::get_by_slug(&self.pool, slug) - .await - .ok() - .flatten()?; - - if !record.is_enabled { - return None; - } - - // Verify the active profile grants access to this subagent. - // If no active profile is set, custom subagents are not available — consistent - // with build_session_spec which injects no custom tools when profile_id is empty. - let active_profile_id = settings_repo::get(&self.pool, "active_profile_id") - .await - .ok() - .flatten() - .and_then(|s| serde_json::from_str::(&s.value_json).ok()) - .unwrap_or_default(); - - if active_profile_id.is_empty() { - return None; - } - - let allowed_ids = custom_subagent_repo::get_profile_access(&self.pool, &active_profile_id) - .await - .unwrap_or_default(); - if !allowed_ids.contains(&record.id) { - return None; - } - - Some(SubagentProfile::Custom { - slug: record.slug.clone(), - system_prompt: record.system_prompt.clone(), - allowed_tools: record.allowed_tools_vec(), - model_role: record.model_role, - }) + crate::core::agent_session_tools::resolve_custom_subagent_profile_from_pool( + &self.pool, slug, + ) + .await } async fn execute_plan_checkpoint(&self, tool_input: &serde_json::Value) -> AgentToolResult { @@ -2096,9 +2087,13 @@ mod tests { review_request: None, helper_profile: Some(SubagentProfile::Custom { slug: "custom".to_string(), + name: "Custom".to_string(), + invocation_description: "custom agent".to_string(), system_prompt: "custom prompt".to_string(), allowed_tools: allowed_tools.into_iter().map(str::to_string).collect(), model_role: CustomSubagentModelRole::Auxiliary, + can_delegate: false, + max_delegation_depth: 3, }), model_role: test_model_role(), } diff --git a/src-tauri/src/core/agent_session_tests.rs b/src-tauri/src/core/agent_session_tests.rs index 0cc8c407..4500c5b3 100644 --- a/src-tauri/src/core/agent_session_tests.rs +++ b/src-tauri/src/core/agent_session_tests.rs @@ -2153,9 +2153,13 @@ Used for prompt assembly coverage. sample_resolved_runtime_model_plan(Some(sample_resolved_model_role("assistant-model"))); let profile = SubagentProfile::Custom { slug: "primary-agent".to_string(), + name: "Primary Agent".to_string(), + invocation_description: "primary agent".to_string(), system_prompt: "Use primary.".to_string(), allowed_tools: vec![], model_role: CustomSubagentModelRole::Primary, + can_delegate: false, + max_delegation_depth: 3, }; let helper_role = resolve_helper_model_role( @@ -2176,9 +2180,13 @@ Used for prompt assembly coverage. ); let profile = SubagentProfile::Custom { slug: "light-agent".to_string(), + name: "Light Agent".to_string(), + invocation_description: "light agent".to_string(), system_prompt: "Use lightweight.".to_string(), allowed_tools: vec![], model_role: CustomSubagentModelRole::Lightweight, + can_delegate: false, + max_delegation_depth: 3, }; let helper_role = resolve_helper_model_role( @@ -2195,9 +2203,13 @@ Used for prompt assembly coverage. fn custom_lightweight_helper_falls_back_to_auxiliary_then_primary() { let profile = SubagentProfile::Custom { slug: "fallback-agent".to_string(), + name: "Fallback Agent".to_string(), + invocation_description: "fallback agent".to_string(), system_prompt: "Fallback.".to_string(), allowed_tools: vec![], model_role: CustomSubagentModelRole::Lightweight, + can_delegate: false, + max_delegation_depth: 3, }; let with_auxiliary = diff --git a/src-tauri/src/core/agent_session_tools.rs b/src-tauri/src/core/agent_session_tools.rs index 05bce99d..abef8647 100644 --- a/src-tauri/src/core/agent_session_tools.rs +++ b/src-tauri/src/core/agent_session_tools.rs @@ -6,12 +6,14 @@ use tiycore::types::{ use crate::core::agent_session_types::{ ResolvedModelRole, ResolvedRuntimeModelPlan, RuntimeModelPlan, }; +use crate::core::subagent::runtime_orchestration::CustomDelegationTarget; use crate::core::subagent::{ runtime_orchestration_tools, RuntimeOrchestrationTool, SubagentProfile, TERM_CLOSE_TOOL_DESCRIPTION, TERM_OUTPUT_TOOL_DESCRIPTION, TERM_RESTART_TOOL_DESCRIPTION, TERM_STATUS_TOOL_DESCRIPTION, TERM_WRITE_TOOL_DESCRIPTION, }; use crate::model::subagent::CustomSubagentModelRole; +use sqlx::SqlitePool; use super::agent_session::{ CLARIFY_TOOL_NAME, DEFAULT_FULL_TOOL_PROFILE, PLAN_MODE_MISSING_CHECKPOINT_ERROR, @@ -710,6 +712,87 @@ fn resolve_custom_helper_model_role( } } +/// Resolve a custom subagent slug into a `SubagentProfile` directly from a pool, +/// validating that the subagent is enabled and accessible from the active agent +/// profile via `profile_subagent_access`. This is a free function (rather than a +/// method on `AgentSession`) so that the helper orchestrator can reuse it when a +/// delegating subagent recursively delegates to a custom subagent. +pub(crate) async fn resolve_custom_subagent_profile_from_pool( + pool: &SqlitePool, + slug: &str, +) -> Option { + use crate::persistence::repo::{custom_subagent_repo, settings_repo}; + + let record = custom_subagent_repo::get_by_slug(pool, slug) + .await + .ok() + .flatten()?; + + if !record.is_enabled { + return None; + } + + // Verify the active profile grants access to this subagent. If no active + // profile is set, custom subagents are not available — consistent with + // build_session_spec which injects no custom tools when profile_id is empty. + let active_profile_id = settings_repo::get(pool, "active_profile_id") + .await + .ok() + .flatten() + .and_then(|s| serde_json::from_str::(&s.value_json).ok()) + .unwrap_or_default(); + + if active_profile_id.is_empty() { + return None; + } + + let allowed_ids = custom_subagent_repo::get_profile_access(pool, &active_profile_id) + .await + .unwrap_or_default(); + if !allowed_ids.contains(&record.id) { + return None; + } + + Some(SubagentProfile::Custom { + slug: record.slug.clone(), + name: record.name.clone(), + invocation_description: record.invocation_description.clone(), + system_prompt: record.system_prompt.clone(), + allowed_tools: record.allowed_tools_vec(), + model_role: record.model_role, + can_delegate: record.can_delegate, + max_delegation_depth: record.max_delegation_depth, + }) +} + +/// List the custom subagents accessible from the active agent profile as +/// `CustomDelegationTarget`s, used to inject `agent_{slug}` delegation tools into +/// a delegating subagent's tool set. Returns an empty vec when no active profile +/// is set or on any lookup error. +pub(crate) async fn list_custom_delegation_targets_from_pool( + pool: &SqlitePool, +) -> Vec { + use crate::persistence::repo::{custom_subagent_repo, settings_repo}; + + let active_profile_id = settings_repo::get(pool, "active_profile_id") + .await + .ok() + .flatten() + .and_then(|s| serde_json::from_str::(&s.value_json).ok()) + .unwrap_or_default(); + + if active_profile_id.is_empty() { + return Vec::new(); + } + + custom_subagent_repo::list_for_profile(pool, &active_profile_id) + .await + .unwrap_or_default() + .iter() + .map(CustomDelegationTarget::from_record) + .collect() +} + /// Maximum characters for a tool result output when replayed from history. /// /// This deliberately oversizes vs. the aggressive (800) and recent (3200) diff --git a/src-tauri/src/core/subagent/orchestrator.rs b/src-tauri/src/core/subagent/orchestrator.rs index ce2e107c..e15a66ff 100644 --- a/src-tauri/src/core/subagent/orchestrator.rs +++ b/src-tauri/src/core/subagent/orchestrator.rs @@ -11,9 +11,13 @@ use tokio::sync::Mutex; use crate::core::agent_session::standard_tool_timeout; use crate::core::agent_session::{merge_payload, ResolvedModelRole}; +use crate::core::agent_session_types::ResolvedRuntimeModelPlan; use crate::core::executors::ToolOutput; +use crate::core::subagent::parallel_contract::ParallelSubagentRequest; use crate::core::subagent::review_contract::{extract_review_report, render_parent_summary}; -use crate::core::subagent::runtime_orchestration::{RuntimeOrchestrationTool, SubagentProfile}; +use crate::core::subagent::runtime_orchestration::{ + CustomDelegationTarget, RuntimeOrchestrationTool, SubagentProfile, GLOBAL_MAX_DELEGATION_DEPTH, +}; use crate::core::tool_gateway::{ ToolExecutionOptions, ToolExecutionRequest, ToolGateway, ToolGatewayResult, }; @@ -44,6 +48,18 @@ pub struct HelperRunRequest { /// DeepSeek thinking-enabled payloads are normalised correctly in the /// subagent payload hook. pub thinking_level: ThinkingLevel, + /// 1-based delegation chain depth of this helper. The main agent is depth 1; + /// a helper it delegates to is depth 2, and so on. Used to enforce the + /// per-agent `max_delegation_depth` and the global safety bound when this + /// helper itself delegates further. + pub delegation_depth: u32, + /// Resolved runtime model plan, propagated so a delegating helper can resolve + /// the appropriate model role for the children it delegates to. + pub model_plan: ResolvedRuntimeModelPlan, + /// Custom subagents accessible from the active profile, propagated so a + /// delegating helper can inject `agent_{slug}` delegation tools and resolve + /// recursive custom delegations without re-reading the active profile. + pub custom_delegation_targets: Vec, } pub struct HelperRunResult { @@ -52,6 +68,61 @@ pub struct HelperRunResult { pub snapshot: SubagentProgressSnapshot, } +/// Context propagated into a delegating helper's tool executor so that it can +/// recursively delegate to other subagents (one more level deep) when the +/// helper's profile allows it. Cloned cheaply (mostly `Arc`s and small owned +/// values) for each delegated tool call. +#[derive(Clone)] +struct HelperDelegationContext { + orchestrator: HelperAgentOrchestrator, + caller_profile: SubagentProfile, + caller_depth: u32, + run_id: String, + thread_id: String, + workspace_path: String, + run_mode: String, + model_plan: ResolvedRuntimeModelPlan, + custom_delegation_targets: Vec, + event_tx: tokio::sync::mpsc::UnboundedSender, + session_abort_signal: tiycore::agent::AbortSignal, + thinking_level: ThinkingLevel, +} + +/// A resolved recursive delegation target produced from a delegating helper's +/// `agent_*` tool call. +struct ResolvedDelegation { + tool: RuntimeOrchestrationTool, + profile: SubagentProfile, + model_role: ResolvedModelRole, + task: String, +} + +/// Extract the helper task (and optional structured review request) from a +/// delegation tool's input. Mirrors `resolve_helper_tool_task` in the main +/// session so recursive delegations build the same prompts. +fn resolve_delegation_task( + tool: &RuntimeOrchestrationTool, + tool_input: &serde_json::Value, +) -> Result<(String, Option), String> { + if *tool == RuntimeOrchestrationTool::Review { + let request = crate::core::subagent::ReviewRequest::from_tool_input(tool_input)?; + return Ok((request.to_helper_prompt(), Some(request))); + } + + let task = tool_input + .get("task") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .trim() + .to_string(); + + if task.is_empty() { + return Err("missing helper task".to_string()); + } + + Ok((task, None)) +} + #[derive(Debug, Clone, Copy, Serialize)] #[serde(rename_all = "snake_case")] pub enum SubagentActivityStatus { @@ -76,6 +147,7 @@ struct RunHelpersState { cancelled: bool, } +#[derive(Clone)] pub struct HelperAgentOrchestrator { pool: SqlitePool, tool_gateway: Arc, @@ -169,7 +241,17 @@ impl HelperAgentOrchestrator { .await .map(|s| s.is_ready()) .unwrap_or(false); - agent.set_tools(helper_profile.helper_tools(web_search_enabled)); + // Base tool set for this helper profile, plus any delegation tools the + // helper is allowed to use. The children this helper would create occupy + // depth = this helper's depth + 1, which gates which delegation tools are + // injected (pre-filter; run_helper re-validates the bound at runtime). + let mut helper_tools = helper_profile.helper_tools(web_search_enabled); + let child_depth = request.delegation_depth.saturating_add(1); + helper_tools.extend( + helper_profile + .delegation_tools_for_helper(child_depth, &request.custom_delegation_targets), + ); + agent.set_tools(helper_tools); agent.set_tool_execution(ToolExecutionMode::Sequential); // Propagate thinking level from the parent session so that the helper @@ -223,6 +305,30 @@ impl HelperAgentOrchestrator { let progress_state_ref = Arc::clone(&progress_state); let progress_event_tx = request.event_tx.clone(); let helper_session_abort_signal = request.session_abort_signal.clone(); + // Delegation context: present only when this helper is allowed to + // delegate further and the children it would create stay within the + // global depth bound. Used by the tool executor to recursively delegate + // `agent_*` tool calls instead of forwarding them to the ToolGateway. + let delegation_ctx = if helper_profile.can_delegate() + && request.delegation_depth < GLOBAL_MAX_DELEGATION_DEPTH + { + Some(HelperDelegationContext { + orchestrator: self.clone(), + caller_profile: helper_profile.clone(), + caller_depth: request.delegation_depth, + run_id: request.run_id.clone(), + thread_id: request.thread_id.clone(), + workspace_path: request.workspace_path.clone(), + run_mode: request.run_mode.clone(), + model_plan: request.model_plan.clone(), + custom_delegation_targets: request.custom_delegation_targets.clone(), + event_tx: request.event_tx.clone(), + session_abort_signal: request.session_abort_signal.clone(), + thinking_level: request.thinking_level, + }) + } else { + None + }; agent.set_tool_executor(move |tool_name, tool_call_id, tool_input, _update_cb| { let tool_name = tool_name.to_string(); let tool_input = tool_input.clone(); @@ -242,8 +348,30 @@ impl HelperAgentOrchestrator { let helper_id_for_storage = helper_id_for_events.clone(); let helper_started_at_for_events = helper_started_at_for_events.clone(); let helper_abort_signal = helper_session_abort_signal.clone(); + let delegation_ctx = delegation_ctx.clone(); async move { + // Recursive delegation: when this helper is allowed to delegate + // and the tool is an `agent_*` orchestration tool, run the child + // subagent via the orchestrator instead of the ToolGateway. + if let Some(ctx) = delegation_ctx.as_ref() { + if let Some(tool) = RuntimeOrchestrationTool::parse(&tool_name) { + return ctx + .handle_delegation( + tool, + &tool_input, + &persisted_tool_call_id, + &progress_event_tx, + &helper_run_id, + &helper_id_for_events, + &helper_kind, + &helper_started_at_for_events, + &progress_state_ref, + ) + .await; + } + } + let action = describe_subagent_action(&tool_name, &tool_input); emit_subagent_progress( &progress_event_tx, @@ -547,6 +675,20 @@ impl HelperAgentOrchestrator { } } + /// Type-erased wrapper around `run_helper` used at recursive delegation + /// boundaries. Returning a boxed `dyn Future` gives the recursion point a + /// concrete, nameable type so the compiler can break the otherwise infinite + /// opaque-type / `Send` inference cycle introduced when a subagent's tool + /// executor awaits another `run_helper`. + pub fn run_helper_boxed( + self, + request: HelperRunRequest, + ) -> std::pin::Pin< + Box> + Send>, + > { + Box::pin(async move { self.run_helper(request).await }) + } + pub async fn cancel_run(&self, run_id: &str) { let helpers = { let mut active = self.active_helpers.lock().await; @@ -576,6 +718,282 @@ impl HelperAgentOrchestrator { } } +impl HelperDelegationContext { + /// Resolve and run a recursive `agent_*` delegation from a delegating helper. + /// Builds a structured tool result mirroring the main session's delegation + /// result shape, and emits subagent progress events for the delegating step. + #[allow(clippy::too_many_arguments)] + async fn handle_delegation( + &self, + tool: RuntimeOrchestrationTool, + tool_input: &serde_json::Value, + parent_tool_call_id: &str, + progress_event_tx: &tokio::sync::mpsc::UnboundedSender, + run_id: &str, + subtask_id: &str, + helper_kind: &str, + started_at: &str, + progress_state: &Arc>, + ) -> AgentToolResult { + let child_depth = self.caller_depth.saturating_add(1); + let tool_label = tool.tool_name(); + let action_label = format!("delegating to {tool_label}"); + emit_subagent_progress( + progress_event_tx, + run_id, + subtask_id, + helper_kind, + started_at, + SubagentActivityStatus::Started, + progress_state, + format!("Delegating to {tool_label}"), + |progress| progress.record_started(&tool_label, &action_label), + ); + + let outcome = match tool.clone() { + RuntimeOrchestrationTool::Parallel => { + self.handle_parallel_delegation(tool_input, parent_tool_call_id) + .await + } + _ => { + self.handle_single_delegation(tool, tool_input, parent_tool_call_id, child_depth) + .await + } + }; + + match outcome { + Ok(result) => { + emit_subagent_progress( + progress_event_tx, + run_id, + subtask_id, + helper_kind, + started_at, + SubagentActivityStatus::Succeeded, + progress_state, + format!("Finished delegating to {tool_label}"), + |progress| progress.record_finished(None), + ); + result + } + Err(error) => { + emit_subagent_progress( + progress_event_tx, + run_id, + subtask_id, + helper_kind, + started_at, + SubagentActivityStatus::Failed, + progress_state, + format!("Failed delegating to {tool_label} ({error})"), + |progress| progress.record_finished(Some(error.clone())), + ); + helper_agent_error_result(error) + } + } + } + + /// Resolve a single (non-parallel) delegation target, enforcing depth and + /// capability bounds, then run the child helper one level deeper. + async fn handle_single_delegation( + &self, + tool: RuntimeOrchestrationTool, + tool_input: &serde_json::Value, + parent_tool_call_id: &str, + child_depth: u32, + ) -> Result { + let delegation = self + .resolve_delegation(tool.clone(), tool_input, child_depth) + .await?; + let result = self + .run_child_delegation(delegation, parent_tool_call_id, child_depth) + .await + .map_err(|error| error.to_string())?; + + let review_report = if tool == RuntimeOrchestrationTool::Review { + result + .raw_summary + .as_deref() + .and_then(extract_review_report) + } else { + None + }; + + let details = serde_json::json!({ + "summary": result.summary.clone(), + "rawSummary": result.raw_summary.clone(), + "snapshot": result.snapshot, + "reviewReport": review_report, + }); + + Ok(AgentToolResult { + content: vec![ContentBlock::Text(TextContent::new(result.summary))], + details: Some(details), + }) + } + + /// Resolve an `agent_parallel` delegation issued by a delegating helper. + /// Each child task is delegated one level deeper and validated against the + /// same depth/capability bounds. Runs sequentially to bound resource usage + /// at deeper levels (concurrency is reserved for the top-level orchestrator). + async fn handle_parallel_delegation( + &self, + tool_input: &serde_json::Value, + parent_tool_call_id: &str, + ) -> Result { + let request = ParallelSubagentRequest::from_tool_input(tool_input)?; + let child_depth = self.caller_depth.saturating_add(1); + let mut summaries = Vec::new(); + + for (index, task) in request.tasks.iter().enumerate() { + let agent_name = task.agent.trim().to_string(); + let Some(tool) = RuntimeOrchestrationTool::parse(&agent_name) else { + summaries.push(format!("[{index}] {agent_name}: unknown subagent tool")); + continue; + }; + if tool == RuntimeOrchestrationTool::Parallel { + summaries.push(format!( + "[{index}] {agent_name}: agent_parallel cannot delegate to itself" + )); + continue; + } + + match self + .resolve_delegation(tool, &task.to_tool_input(), child_depth) + .await + { + Ok(delegation) => match self + .run_child_delegation(delegation, parent_tool_call_id, child_depth) + .await + { + Ok(result) => { + summaries.push(format!("[{index}] {agent_name}:\n{}", result.summary)) + } + Err(error) => summaries.push(format!("[{index}] {agent_name}: {error}")), + }, + Err(error) => summaries.push(format!("[{index}] {agent_name}: {error}")), + } + } + + let summary = summaries.join("\n\n"); + Ok(AgentToolResult { + content: vec![ContentBlock::Text(TextContent::new(summary.clone()))], + details: Some(serde_json::json!({ "summary": summary })), + }) + } + + /// Resolve a delegation tool into a concrete child profile + model role, + /// enforcing the caller's capability and the target's max delegation depth + /// plus the global safety bound. + fn resolve_delegation_sync( + &self, + tool: RuntimeOrchestrationTool, + profile: SubagentProfile, + tool_input: &serde_json::Value, + child_depth: u32, + ) -> Result { + if !self.caller_profile.can_delegate() { + return Err(format!( + "{} is not allowed to delegate to other subagents", + self.caller_profile.helper_kind() + )); + } + if child_depth > GLOBAL_MAX_DELEGATION_DEPTH { + return Err(format!( + "delegation depth {child_depth} exceeds the global maximum {GLOBAL_MAX_DELEGATION_DEPTH}" + )); + } + if child_depth > profile.max_delegation_depth() { + return Err(format!( + "{} cannot be delegated at depth {child_depth} (its max delegation depth is {})", + tool.tool_name(), + profile.max_delegation_depth() + )); + } + + let (task, _review_request) = resolve_delegation_task(&tool, tool_input)?; + let model_role = crate::core::agent_session_tools::resolve_helper_model_role( + &self.model_plan, + &tool, + Some(&profile), + ) + .ok_or_else(|| format!("No helper profile resolved for tool '{}'", tool.tool_name()))?; + + Ok(ResolvedDelegation { + tool, + profile, + model_role, + task, + }) + } + + /// Async resolution wrapper: loads built-in or custom profile, then applies + /// the synchronous capability/depth checks. + async fn resolve_delegation( + &self, + tool: RuntimeOrchestrationTool, + tool_input: &serde_json::Value, + child_depth: u32, + ) -> Result { + let profile = match &tool { + RuntimeOrchestrationTool::Explore => SubagentProfile::Explore, + RuntimeOrchestrationTool::Review => SubagentProfile::Review, + RuntimeOrchestrationTool::Parallel => { + return Err("agent_parallel cannot be used as an individual helper".to_string()); + } + RuntimeOrchestrationTool::Custom(slug) => { + crate::core::agent_session_tools::resolve_custom_subagent_profile_from_pool( + &self.orchestrator.pool, + slug, + ) + .await + .ok_or_else(|| format!("Custom subagent 'agent_{slug}' not found or not enabled"))? + } + }; + + self.resolve_delegation_sync(tool, profile, tool_input, child_depth) + } + + /// Run the resolved child delegation via the orchestrator at the next depth, + /// reusing the same run/abort signal so cancellation cascades correctly. + /// + /// The recursive `run_helper` call is routed through `run_helper_boxed`, + /// which returns a type-erased `Pin>`. This is + /// required to break the otherwise infinite opaque-type recursion in the + /// tool-executor future: the executor future awaits the child's `run_helper`, + /// which installs another tool-executor future, and so on. Boxing names the + /// recursion point with a concrete type so the compiler can terminate type + /// inference, while cancellation still works through the shared `run_id` / + /// `active_helpers` registration and abort signal. + async fn run_child_delegation( + &self, + delegation: ResolvedDelegation, + parent_tool_call_id: &str, + child_depth: u32, + ) -> Result { + let request = HelperRunRequest { + run_id: self.run_id.clone(), + thread_id: self.thread_id.clone(), + tool: delegation.tool, + helper_profile: Some(delegation.profile), + parent_tool_call_id: Some(parent_tool_call_id.to_string()), + task: delegation.task, + model_role: delegation.model_role, + system_prompt: String::new(), + workspace_path: self.workspace_path.clone(), + run_mode: self.run_mode.clone(), + event_tx: self.event_tx.clone(), + session_abort_signal: self.session_abort_signal.clone(), + thinking_level: self.thinking_level, + delegation_depth: child_depth, + model_plan: self.model_plan.clone(), + custom_delegation_targets: self.custom_delegation_targets.clone(), + }; + + self.orchestrator.clone().run_helper_boxed(request).await + } +} + fn finalize_helper_summary(helper_profile: SubagentProfile, raw_summary: &str) -> String { if helper_profile == SubagentProfile::Review { extract_review_report(raw_summary) @@ -1167,9 +1585,13 @@ mod tests { let pool = placeholder_pool(); let profile = SubagentProfile::Custom { slug: "tester".to_string(), + name: "Tester".to_string(), + invocation_description: "test helper".to_string(), system_prompt: "You are a test helper.".to_string(), allowed_tools: vec!["read".to_string(), "search".to_string()], model_role: crate::model::subagent::CustomSubagentModelRole::Auxiliary, + can_delegate: false, + max_delegation_depth: 3, }; let result = build_helper_system_prompt(&pool, "/tmp/test", "default", "thread-custom", &profile) @@ -1189,9 +1611,13 @@ mod tests { let pool = placeholder_pool(); let profile_custom = SubagentProfile::Custom { slug: "tester".to_string(), + name: "Tester".to_string(), + invocation_description: "test helper".to_string(), system_prompt: "You are a test helper.".to_string(), allowed_tools: vec!["read".to_string(), "search".to_string()], model_role: crate::model::subagent::CustomSubagentModelRole::Auxiliary, + can_delegate: false, + max_delegation_depth: 3, }; let explore = build_helper_system_prompt( diff --git a/src-tauri/src/core/subagent/runtime_orchestration.rs b/src-tauri/src/core/subagent/runtime_orchestration.rs index bd8e7a15..27150c30 100644 --- a/src-tauri/src/core/subagent/runtime_orchestration.rs +++ b/src-tauri/src/core/subagent/runtime_orchestration.rs @@ -3,9 +3,19 @@ use crate::core::subagent::parallel_contract::{ PARALLEL_SUBAGENT_DEFAULT_CONCURRENCY, PARALLEL_SUBAGENT_MAX_CONCURRENCY, PARALLEL_SUBAGENT_MAX_TASKS, }; -use crate::model::subagent::CustomSubagentModelRole; +use crate::model::subagent::{CustomSubagentModelRole, CustomSubagentRecord}; use tiycore::agent::AgentTool; +/// Hard upper bound on the delegation chain depth (1-based). The main agent is +/// depth 1; each delegated subagent increments the depth by one. No subagent may +/// be created beyond this depth regardless of per-agent configuration, guarding +/// against unbounded recursion. +pub const GLOBAL_MAX_DELEGATION_DEPTH: u32 = 5; + +/// Built-in default for the maximum delegation depth a built-in subagent +/// (explore / review) may be delegated to. +pub const BUILTIN_DEFAULT_MAX_DELEGATION_DEPTH: u32 = 3; + pub const TERM_STATUS_TOOL_DESCRIPTION: &str = "Inspect the status of the desktop app's embedded Terminal panel session for the current thread. Use this to check that panel's session state without mutating it. It does not inspect the agent runtime, CLI process, or host shell outside the panel."; pub const TERM_OUTPUT_TOOL_DESCRIPTION: &str = @@ -33,12 +43,56 @@ pub enum SubagentProfile { Review, Custom { slug: String, + name: String, + invocation_description: String, system_prompt: String, allowed_tools: Vec, model_role: CustomSubagentModelRole, + can_delegate: bool, + max_delegation_depth: u32, }, } +/// A custom subagent that the active profile grants access to, used to inject +/// `agent_{slug}` delegation tools into a delegating subagent's tool set. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CustomDelegationTarget { + pub slug: String, + pub name: String, + pub invocation_description: String, + pub max_delegation_depth: u32, +} + +impl CustomDelegationTarget { + pub fn from_record(record: &CustomSubagentRecord) -> Self { + Self { + slug: record.slug.clone(), + name: record.name.clone(), + invocation_description: record.invocation_description.clone(), + max_delegation_depth: record.max_delegation_depth, + } + } + + fn as_agent_tool(&self) -> AgentTool { + let tool_name = format!("agent_{}", self.slug); + AgentTool::new( + &tool_name, + &self.name, + &self.invocation_description, + serde_json::json!({ + "type": "object", + "properties": { + "task": { + "type": "string", + "description": "What task to delegate to this subagent. Be specific about goals, relevant files, and expected output format." + } + }, + "required": ["task"] + }), + ) + } +} + pub fn runtime_orchestration_tools() -> Vec { RuntimeOrchestrationTool::builtin_all() .into_iter() @@ -303,6 +357,75 @@ impl SubagentProfile { } } + /// Whether this agent is allowed to delegate to other subagents. + /// Built-in explore cannot delegate; built-in review can; custom agents + /// follow their configured `can_delegate` flag. + pub fn can_delegate(&self) -> bool { + match self { + Self::Explore => false, + Self::Review => true, + Self::Custom { can_delegate, .. } => *can_delegate, + } + } + + /// The maximum delegation chain depth (1-based) this agent may be delegated + /// to. Built-in subagents default to `BUILTIN_DEFAULT_MAX_DELEGATION_DEPTH`; + /// custom agents follow their configured value. + pub fn max_delegation_depth(&self) -> u32 { + match self { + Self::Explore | Self::Review => BUILTIN_DEFAULT_MAX_DELEGATION_DEPTH, + Self::Custom { + max_delegation_depth, + .. + } => *max_delegation_depth, + } + } + + /// Build the set of `agent_*` delegation tools to inject into this helper's + /// tool set. Returns an empty vec when the helper is not allowed to delegate + /// (`can_delegate() == false`) or when `child_depth` already exceeds the + /// global safety bound. `child_depth` is the 1-based depth that any child + /// the helper creates would occupy (i.e. this helper's depth + 1). + /// + /// Built-in explore/review and each accessible custom target are pre-filtered + /// so that a tool is only injected when `child_depth <= target_max_depth`. + /// Runtime re-validates the same bound as a backstop. + pub fn delegation_tools_for_helper( + &self, + child_depth: u32, + custom_targets: &[CustomDelegationTarget], + ) -> Vec { + if !self.can_delegate() || child_depth > GLOBAL_MAX_DELEGATION_DEPTH { + return Vec::new(); + } + + let mut tools = Vec::new(); + let mut delegatable = false; + + // Built-in explore / review (both default depth = BUILTIN_DEFAULT_MAX_DELEGATION_DEPTH). + if child_depth <= BUILTIN_DEFAULT_MAX_DELEGATION_DEPTH { + tools.push(RuntimeOrchestrationTool::Explore.as_agent_tool()); + tools.push(RuntimeOrchestrationTool::Review.as_agent_tool()); + delegatable = true; + } + + // Custom subagents accessible from the active profile. + for target in custom_targets { + if child_depth <= target.max_delegation_depth { + tools.push(target.as_agent_tool()); + delegatable = true; + } + } + + // agent_parallel is a tool, not an agent: only inject it when at least + // one delegatable target exists for the children it would schedule. + if delegatable { + tools.push(RuntimeOrchestrationTool::Parallel.as_agent_tool()); + } + + tools + } + /// Subagent body is now rendered by SubagentBodySource via the Composer. /// This stub exists only for backward-compat tests that need to construct /// SubagentProfile values; production code must use `Composer::build` with @@ -701,7 +824,10 @@ impl SubagentProfile { #[cfg(test)] mod tests { - use super::{runtime_orchestration_tools, RuntimeOrchestrationTool, SubagentProfile}; + use super::{ + runtime_orchestration_tools, CustomDelegationTarget, RuntimeOrchestrationTool, + SubagentProfile, GLOBAL_MAX_DELEGATION_DEPTH, + }; use crate::model::subagent::CustomSubagentModelRole; #[test] @@ -862,9 +988,13 @@ mod tests { fn custom_profile_resolves_tools_from_allowlist() { let profile = SubagentProfile::Custom { slug: "test".to_string(), + name: "Test".to_string(), + invocation_description: "desc".to_string(), system_prompt: "You are a test helper.".to_string(), allowed_tools: vec!["read".to_string(), "search".to_string()], model_role: CustomSubagentModelRole::Auxiliary, + can_delegate: false, + max_delegation_depth: 3, }; let tools = profile.helper_tools(false); let tool_names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect(); @@ -879,13 +1009,78 @@ mod tests { fn custom_profile_can_allow_web_search_tool() { let profile = SubagentProfile::Custom { slug: "test".to_string(), + name: "Test".to_string(), + invocation_description: "desc".to_string(), system_prompt: "You are a test helper.".to_string(), allowed_tools: vec!["web_search".to_string()], model_role: CustomSubagentModelRole::Auxiliary, + can_delegate: false, + max_delegation_depth: 3, }; let tools = profile.helper_tools(false); let tool_names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect(); assert!(tool_names.contains(&"web_search")); assert!(!tool_names.contains(&"read")); } + + #[test] + fn explore_profile_cannot_delegate() { + assert!(!SubagentProfile::Explore.can_delegate()); + let tools = SubagentProfile::Explore.delegation_tools_for_helper(2, &[]); + assert!(tools.is_empty()); + } + + #[test] + fn review_profile_injects_builtin_delegation_tools_within_depth() { + assert!(SubagentProfile::Review.can_delegate()); + let tools = SubagentProfile::Review.delegation_tools_for_helper(2, &[]); + let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect(); + assert!(names.contains(&"agent_explore")); + assert!(names.contains(&"agent_review")); + assert!(names.contains(&"agent_parallel")); + } + + #[test] + fn review_profile_omits_delegation_tools_beyond_builtin_depth() { + // child_depth 4 exceeds BUILTIN_DEFAULT_MAX_DELEGATION_DEPTH (3). + let tools = SubagentProfile::Review.delegation_tools_for_helper(4, &[]); + assert!(tools.is_empty()); + } + + #[test] + fn delegation_tools_include_accessible_custom_targets_within_depth() { + let targets = vec![ + CustomDelegationTarget { + slug: "deep".to_string(), + name: "Deep".to_string(), + invocation_description: "deep agent".to_string(), + max_delegation_depth: 5, + }, + CustomDelegationTarget { + slug: "shallow".to_string(), + name: "Shallow".to_string(), + invocation_description: "shallow agent".to_string(), + max_delegation_depth: 1, + }, + ]; + let tools = SubagentProfile::Review.delegation_tools_for_helper(2, &targets); + let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect(); + assert!(names.contains(&"agent_deep")); + // shallow.max_delegation_depth (1) < child_depth (2), so excluded. + assert!(!names.contains(&"agent_shallow")); + } + + #[test] + fn delegation_tools_respect_global_max_depth() { + let targets = vec![CustomDelegationTarget { + slug: "deep".to_string(), + name: "Deep".to_string(), + invocation_description: "deep agent".to_string(), + max_delegation_depth: 5, + }]; + // child_depth exceeding GLOBAL_MAX_DELEGATION_DEPTH yields no tools. + let tools = SubagentProfile::Review + .delegation_tools_for_helper(GLOBAL_MAX_DELEGATION_DEPTH + 1, &targets); + assert!(tools.is_empty()); + } } diff --git a/src-tauri/src/model/subagent.rs b/src-tauri/src/model/subagent.rs index a6c4c752..d7207ab8 100644 --- a/src-tauri/src/model/subagent.rs +++ b/src-tauri/src/model/subagent.rs @@ -50,6 +50,8 @@ pub struct CustomSubagentRecord { pub allowed_tools: String, // JSON array string, e.g. '["read","list","search"]' pub model_role: CustomSubagentModelRole, pub is_enabled: bool, + pub can_delegate: bool, + pub max_delegation_depth: u32, pub created_at: String, pub updated_at: String, } @@ -76,6 +78,8 @@ pub struct CustomSubagentDto { pub allowed_tools: Vec, pub model_role: CustomSubagentModelRole, pub is_enabled: bool, + pub can_delegate: bool, + pub max_delegation_depth: u32, pub created_at: String, pub updated_at: String, } @@ -92,6 +96,8 @@ impl From for CustomSubagentDto { allowed_tools: tools, model_role: r.model_role, is_enabled: r.is_enabled, + can_delegate: r.can_delegate, + max_delegation_depth: r.max_delegation_depth, created_at: r.created_at, updated_at: r.updated_at, } @@ -113,6 +119,8 @@ pub struct CustomSubagentInput { #[serde(default)] pub model_role: CustomSubagentModelRole, pub is_enabled: Option, + pub can_delegate: Option, + pub max_delegation_depth: Option, } // --------------------------------------------------------------------------- diff --git a/src-tauri/src/persistence/repo/custom_subagent_repo.rs b/src-tauri/src/persistence/repo/custom_subagent_repo.rs index d8030707..379ad9ba 100644 --- a/src-tauri/src/persistence/repo/custom_subagent_repo.rs +++ b/src-tauri/src/persistence/repo/custom_subagent_repo.rs @@ -19,6 +19,8 @@ struct SubagentRow { allowed_tools: String, model_role: String, is_enabled: i32, + can_delegate: i32, + max_delegation_depth: i32, created_at: String, updated_at: String, } @@ -34,13 +36,15 @@ impl SubagentRow { allowed_tools: self.allowed_tools, model_role: CustomSubagentModelRole::from_db(&self.model_role), is_enabled: self.is_enabled != 0, + can_delegate: self.can_delegate != 0, + max_delegation_depth: self.max_delegation_depth as u32, created_at: self.created_at, updated_at: self.updated_at, } } } -const SUBAGENT_COLUMNS: &str = "id, name, slug, system_prompt, invocation_description, allowed_tools, model_role, is_enabled, created_at, updated_at"; +const SUBAGENT_COLUMNS: &str = "id, name, slug, system_prompt, invocation_description, allowed_tools, model_role, is_enabled, can_delegate, max_delegation_depth, created_at, updated_at"; // --------------------------------------------------------------------------- // CRUD operations @@ -102,11 +106,18 @@ pub async fn create( } else { 0 }; + let can_delegate: i32 = if input.can_delegate.unwrap_or(false) { + 1 + } else { + 0 + }; + let max_depth = input.max_delegation_depth.unwrap_or(3); + let max_depth_val: i32 = max_depth.clamp(1, 5) as i32; let tools_json = serde_json::to_string(&input.allowed_tools).unwrap_or_else(|_| "[]".to_string()); sqlx::query( - "INSERT INTO custom_subagents (id, name, slug, system_prompt, invocation_description, allowed_tools, model_role, is_enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO custom_subagents (id, name, slug, system_prompt, invocation_description, allowed_tools, model_role, is_enabled, can_delegate, max_delegation_depth, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(&id) .bind(&input.name) @@ -116,6 +127,8 @@ pub async fn create( .bind(&tools_json) .bind(input.model_role.as_str()) .bind(is_enabled) + .bind(can_delegate) + .bind(max_depth_val) .bind(&now) .bind(&now) .execute(pool) @@ -141,6 +154,8 @@ pub async fn create( allowed_tools: tools_json, model_role: input.model_role, is_enabled: is_enabled != 0, + can_delegate: can_delegate != 0, + max_delegation_depth: max_depth_val as u32, created_at: now.clone(), updated_at: now, }) @@ -168,11 +183,17 @@ pub async fn update( let is_enabled_val = input.is_enabled.unwrap_or_else(|| existing.is_enabled); let is_enabled: i32 = if is_enabled_val { 1 } else { 0 }; + let can_delegate_val = input.can_delegate.unwrap_or_else(|| existing.can_delegate); + let can_delegate: i32 = if can_delegate_val { 1 } else { 0 }; + let max_depth_val = input + .max_delegation_depth + .unwrap_or_else(|| existing.max_delegation_depth); + let max_depth_val: i32 = max_depth_val.clamp(1, 5) as i32; let tools_json = serde_json::to_string(&input.allowed_tools).unwrap_or_else(|_| "[]".to_string()); sqlx::query( - "UPDATE custom_subagents SET name = ?, slug = ?, system_prompt = ?, invocation_description = ?, allowed_tools = ?, model_role = ?, is_enabled = ?, updated_at = ? WHERE id = ?", + "UPDATE custom_subagents SET name = ?, slug = ?, system_prompt = ?, invocation_description = ?, allowed_tools = ?, model_role = ?, is_enabled = ?, can_delegate = ?, max_delegation_depth = ?, updated_at = ? WHERE id = ?", ) .bind(&input.name) .bind(&input.slug) @@ -181,6 +202,8 @@ pub async fn update( .bind(&tools_json) .bind(input.model_role.as_str()) .bind(is_enabled) + .bind(can_delegate) + .bind(max_depth_val) .bind(&now) .bind(id) .execute(pool) @@ -207,6 +230,8 @@ pub async fn update( allowed_tools: tools_json, model_role: input.model_role, is_enabled: is_enabled_val, + can_delegate: can_delegate_val, + max_delegation_depth: max_depth_val as u32, created_at: existing.created_at.clone(), updated_at: now, }) @@ -281,7 +306,7 @@ pub async fn list_for_profile( profile_id: &str, ) -> Result, AppError> { let rows = sqlx::query_as::<_, SubagentRow>(&format!( - "SELECT s.id, s.name, s.slug, s.system_prompt, s.invocation_description, s.allowed_tools, s.model_role, s.is_enabled, s.created_at, s.updated_at \ + "SELECT s.id, s.name, s.slug, s.system_prompt, s.invocation_description, s.allowed_tools, s.model_role, s.is_enabled, s.can_delegate, s.max_delegation_depth, s.created_at, s.updated_at \ FROM custom_subagents s \ INNER JOIN profile_subagent_access a ON s.id = a.subagent_id \ WHERE a.profile_id = ? AND s.is_enabled = 1 \ diff --git a/src-tauri/tests/custom_subagent.rs b/src-tauri/tests/custom_subagent.rs index 0f5d48f9..a3e9fc0b 100644 --- a/src-tauri/tests/custom_subagent.rs +++ b/src-tauri/tests/custom_subagent.rs @@ -38,6 +38,8 @@ async fn custom_subagent_crud_lifecycle() { allowed_tools: vec!["read".to_string(), "edit".to_string(), "search".to_string()], model_role: CustomSubagentModelRole::Primary, is_enabled: Some(true), + can_delegate: None, + max_delegation_depth: None, }; let created = custom_subagent_repo::create(&pool, &input) .await @@ -78,6 +80,8 @@ async fn custom_subagent_crud_lifecycle() { allowed_tools: vec!["read".to_string(), "edit".to_string(), "write".to_string()], model_role: CustomSubagentModelRole::Lightweight, is_enabled: Some(false), + can_delegate: None, + max_delegation_depth: None, }; let updated = custom_subagent_repo::update(&pool, &created.id, &update_input) .await @@ -113,6 +117,8 @@ async fn slug_uniqueness_constraint() { allowed_tools: vec![], model_role: CustomSubagentModelRole::Auxiliary, is_enabled: Some(true), + can_delegate: None, + max_delegation_depth: None, }; custom_subagent_repo::create(&pool, &input) .await @@ -165,6 +171,8 @@ async fn profile_subagent_access_set_and_get() { allowed_tools: vec!["read".to_string()], model_role: CustomSubagentModelRole::Auxiliary, is_enabled: Some(true), + can_delegate: None, + max_delegation_depth: None, }; let input_b = CustomSubagentInput { name: "Agent B".to_string(), @@ -174,6 +182,8 @@ async fn profile_subagent_access_set_and_get() { allowed_tools: vec!["read".to_string()], model_role: CustomSubagentModelRole::Auxiliary, is_enabled: Some(true), + can_delegate: None, + max_delegation_depth: None, }; let a = custom_subagent_repo::create(&pool, &input_a).await.unwrap(); let b = custom_subagent_repo::create(&pool, &input_b).await.unwrap(); @@ -252,6 +262,8 @@ async fn cascade_delete_subagent_removes_access() { allowed_tools: vec![], model_role: CustomSubagentModelRole::Auxiliary, is_enabled: Some(true), + can_delegate: None, + max_delegation_depth: None, }; let agent = custom_subagent_repo::create(&pool, &input).await.unwrap(); custom_subagent_repo::set_profile_access(&pool, "cascade-profile", &[agent.id.clone()]) @@ -285,3 +297,72 @@ async fn slug_validation_rejects_reserved_and_invalid() { assert!(validate_slug("code-review").is_ok()); assert!(validate_slug("a123").is_ok()); } + +#[tokio::test] +async fn custom_subagent_delegation_fields_persist_and_clamp() { + use tiycode_lib::persistence::repo::custom_subagent_repo; + + let pool = setup_test_pool().await; + + // Default (None) → can_delegate=false, max_delegation_depth=3. + let default_input = CustomSubagentInput { + name: "Default Agent".to_string(), + slug: "default-agent".to_string(), + system_prompt: "prompt".to_string(), + invocation_description: "desc".to_string(), + allowed_tools: vec!["read".to_string()], + model_role: CustomSubagentModelRole::Auxiliary, + is_enabled: Some(true), + can_delegate: None, + max_delegation_depth: None, + }; + let created = custom_subagent_repo::create(&pool, &default_input) + .await + .expect("create should succeed"); + assert!(!created.can_delegate); + assert_eq!(created.max_delegation_depth, 3); + + // Explicit can_delegate=true and an out-of-range depth that must clamp to 5. + let delegating_input = CustomSubagentInput { + name: "Delegating Agent".to_string(), + slug: "delegating-agent".to_string(), + system_prompt: "prompt".to_string(), + invocation_description: "desc".to_string(), + allowed_tools: vec!["read".to_string()], + model_role: CustomSubagentModelRole::Auxiliary, + is_enabled: Some(true), + can_delegate: Some(true), + max_delegation_depth: Some(99), + }; + let delegating = custom_subagent_repo::create(&pool, &delegating_input) + .await + .expect("create should succeed"); + assert!(delegating.can_delegate); + assert_eq!(delegating.max_delegation_depth, 5, "depth must clamp to 5"); + + // Reload from DB to confirm persistence. + let reloaded = custom_subagent_repo::get_by_slug(&pool, "delegating-agent") + .await + .expect("get_by_slug should succeed") + .expect("should find record"); + assert!(reloaded.can_delegate); + assert_eq!(reloaded.max_delegation_depth, 5); + + // Update can lower the depth and toggle can_delegate off; a too-low value clamps to 1. + let update_input = CustomSubagentInput { + name: "Delegating Agent".to_string(), + slug: "delegating-agent".to_string(), + system_prompt: "prompt".to_string(), + invocation_description: "desc".to_string(), + allowed_tools: vec!["read".to_string()], + model_role: CustomSubagentModelRole::Auxiliary, + is_enabled: Some(true), + can_delegate: Some(false), + max_delegation_depth: Some(0), + }; + let updated = custom_subagent_repo::update(&pool, &delegating.id, &update_input) + .await + .expect("update should succeed"); + assert!(!updated.can_delegate); + assert_eq!(updated.max_delegation_depth, 1, "depth must clamp to 1"); +} diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 784e7e68..fb8b6ee7 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -495,6 +495,12 @@ const en: Record = { "settings.agents.builtIn.review.desc": "Focused code review and verification after an implementation is complete.", "settings.agents.alwaysAvailable": "Always available", "settings.agents.builtInBadge": "Built-in", + "settings.agents.delegation": "Agent Delegation", + "settings.agents.delegationDesc": "Control whether this custom agent can delegate work to other agents.", + "settings.agents.canDelegate": "Allow delegating to other agents", + "settings.agents.canDelegateDesc": "When enabled, this agent will have agent_* tools available to spawn sub-agents for exploration, review, or parallel work.", + "settings.agents.maxDelegationDepth": "Maximum delegation depth", + "settings.agents.maxDelegationDepthDesc": "The deepest level this agent can be placed at in a delegation chain. Level 1 is the main agent, level 2 is a direct sub-agent, etc. Default: 3.", "settings.agents.unsavedChanges": "Unsaved changes", "settings.agents.unsaved": "Unsaved", "settings.agents.saved": "Saved", diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 56139f90..6a66afd3 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -513,6 +513,12 @@ const zhCN = { "settings.agents.builtIn.review.desc": "在实现完成后进行代码审查、回归探测并报告验证状态。", "settings.agents.alwaysAvailable": "始终可用", "settings.agents.builtInBadge": "内置", + "settings.agents.delegation": "Agent 委派", + "settings.agents.delegationDesc": "控制此自定义 Agent 是否可以将工作委派给其他 Agent。", + "settings.agents.canDelegate": "允许委派其他 Agent", + "settings.agents.canDelegateDesc": "启用后,此 Agent 将拥有 agent_* 工具,可生成子 Agent 进行探索、审查或并行工作。", + "settings.agents.maxDelegationDepth": "最大被委派深度", + "settings.agents.maxDelegationDepthDesc": "此 Agent 在委派链中可处于的最深层级。第 1 层为主 Agent,第 2 层为直接子 Agent,以此类推。默认:3。", "settings.agents.unsavedChanges": "有未保存修改", "settings.agents.unsaved": "未保存", "settings.agents.saved": "已保存", diff --git a/src/modules/settings-center/model/types.ts b/src/modules/settings-center/model/types.ts index 378115f2..c29ceb83 100644 --- a/src/modules/settings-center/model/types.ts +++ b/src/modules/settings-center/model/types.ts @@ -177,6 +177,8 @@ export type CustomSubagent = { allowedTools: string[]; modelRole: CustomSubagentModelRole; isEnabled: boolean; + canDelegate: boolean; + maxDelegationDepth: number; createdAt: string; updatedAt: string; }; diff --git a/src/modules/settings-center/ui/agents-settings-panel.tsx b/src/modules/settings-center/ui/agents-settings-panel.tsx index f4573083..fd2f67c8 100644 --- a/src/modules/settings-center/ui/agents-settings-panel.tsx +++ b/src/modules/settings-center/ui/agents-settings-panel.tsx @@ -179,6 +179,8 @@ export function AgentsSettingsPanel({ allowedTools: ["read", "list", "search", "find"], modelRole: "auxiliary", isEnabled: true, + canDelegate: false, + maxDelegationDepth: 3, }; try { const created = await customSubagentCreate(input); @@ -446,6 +448,50 @@ export function AgentsSettingsPanel({ + +
+ + updateField("canDelegate", checked)} + /> +
+ +
+ + + { From fe154241be7f15b6cb3c465266a1ee6c11e3edc4 Mon Sep 17 00:00:00 2001 From: Jorben Date: Sat, 6 Jun 2026 23:17:43 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(agents):=20=F0=9F=90=9B=20save=20delega?= =?UTF-8?q?tion=20toggles=20and=20bound=20parallel=20concurrency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/core/agent_session_execution.rs | 26 +--- src-tauri/src/core/subagent/orchestrator.rs | 128 ++++++++++++++---- src/i18n/locales/en.ts | 1 + src/i18n/locales/zh-CN.ts | 1 + .../ui/agents-settings-panel.tsx | 7 + 5 files changed, 116 insertions(+), 47 deletions(-) diff --git a/src-tauri/src/core/agent_session_execution.rs b/src-tauri/src/core/agent_session_execution.rs index 404eeb0e..375cc493 100644 --- a/src-tauri/src/core/agent_session_execution.rs +++ b/src-tauri/src/core/agent_session_execution.rs @@ -53,28 +53,13 @@ fn resolve_helper_tool_task( tool: RuntimeOrchestrationTool, tool_input: &serde_json::Value, ) -> Result { - if tool == RuntimeOrchestrationTool::Review { - let request = ReviewRequest::from_tool_input(tool_input)?; - return Ok(HelperToolTask { - task: request.to_helper_prompt(), - review_request: Some(request), - }); - } - - let task = tool_input - .get("task") - .and_then(serde_json::Value::as_str) - .unwrap_or_default() - .trim() - .to_string(); - - if task.is_empty() { - return Err("missing helper task".to_string()); - } - + // Shares the task/review-request extraction with the recursive subagent + // delegation path to avoid divergent prompt construction. + let (task, review_request) = + crate::core::subagent::orchestrator::resolve_delegation_task(&tool, tool_input)?; Ok(HelperToolTask { task, - review_request: None, + review_request, }) } @@ -965,7 +950,6 @@ impl AgentSession { parent_tool_call_id: Some(parent_tool_call_id.to_string()), task: delegate.task.clone(), model_role: delegate.model_role.clone(), - system_prompt: self.spec.system_prompt.clone(), workspace_path: self.spec.workspace_path.clone(), run_mode: self.spec.run_mode.clone(), event_tx: self.event_tx.clone(), diff --git a/src-tauri/src/core/subagent/orchestrator.rs b/src-tauri/src/core/subagent/orchestrator.rs index e15a66ff..a3f3b0de 100644 --- a/src-tauri/src/core/subagent/orchestrator.rs +++ b/src-tauri/src/core/subagent/orchestrator.rs @@ -36,7 +36,6 @@ pub struct HelperRunRequest { pub parent_tool_call_id: Option, pub task: String, pub model_role: ResolvedModelRole, - pub system_prompt: String, pub workspace_path: String, pub run_mode: String, pub event_tx: tokio::sync::mpsc::UnboundedSender, @@ -98,9 +97,9 @@ struct ResolvedDelegation { } /// Extract the helper task (and optional structured review request) from a -/// delegation tool's input. Mirrors `resolve_helper_tool_task` in the main -/// session so recursive delegations build the same prompts. -fn resolve_delegation_task( +/// delegation tool's input. Shared by the main session's `resolve_helper_tool_task` +/// and the recursive subagent delegation path so both build identical prompts. +pub(crate) fn resolve_delegation_task( tool: &RuntimeOrchestrationTool, tool_input: &serde_json::Value, ) -> Result<(String, Option), String> { @@ -834,48 +833,126 @@ impl HelperDelegationContext { /// Resolve an `agent_parallel` delegation issued by a delegating helper. /// Each child task is delegated one level deeper and validated against the - /// same depth/capability bounds. Runs sequentially to bound resource usage - /// at deeper levels (concurrency is reserved for the top-level orchestrator). + /// same depth/capability bounds. Tasks run with bounded concurrency + /// (`maxConcurrency`, default/clamped by the parallel contract) using a + /// `FuturesUnordered` scheduler, mirroring the top-level orchestrator's + /// behaviour so that subagent-issued `agent_parallel` is genuinely parallel + /// rather than sequential. Honours `failFast` and the session abort signal. async fn handle_parallel_delegation( &self, tool_input: &serde_json::Value, parent_tool_call_id: &str, ) -> Result { + use futures::stream::{FuturesUnordered, StreamExt}; + let request = ParallelSubagentRequest::from_tool_input(tool_input)?; let child_depth = self.caller_depth.saturating_add(1); - let mut summaries = Vec::new(); + let max_concurrency = request.effective_max_concurrency(); + + // Pre-validate each task into either an immediate error (kept with its + // index for ordered output) or a queued delegation candidate. + let mut indexed_summaries: Vec<(usize, String)> = Vec::new(); + let mut queued: std::collections::VecDeque<(usize, String, RuntimeOrchestrationTool)> = + std::collections::VecDeque::new(); for (index, task) in request.tasks.iter().enumerate() { let agent_name = task.agent.trim().to_string(); let Some(tool) = RuntimeOrchestrationTool::parse(&agent_name) else { - summaries.push(format!("[{index}] {agent_name}: unknown subagent tool")); + indexed_summaries.push(( + index, + format!("[{index}] {agent_name}: unknown subagent tool"), + )); continue; }; if tool == RuntimeOrchestrationTool::Parallel { - summaries.push(format!( - "[{index}] {agent_name}: agent_parallel cannot delegate to itself" + indexed_summaries.push(( + index, + format!("[{index}] {agent_name}: agent_parallel cannot delegate to itself"), )); continue; } + queued.push_back((index, agent_name, tool)); + } - match self - .resolve_delegation(tool, &task.to_tool_input(), child_depth) - .await - { - Ok(delegation) => match self - .run_child_delegation(delegation, parent_tool_call_id, child_depth) - .await - { - Ok(result) => { - summaries.push(format!("[{index}] {agent_name}:\n{}", result.summary)) - } - Err(error) => summaries.push(format!("[{index}] {agent_name}: {error}")), - }, - Err(error) => summaries.push(format!("[{index}] {agent_name}: {error}")), + let parent_tool_call_id = parent_tool_call_id.to_string(); + let tasks = &request.tasks; + + // Run one queued delegation, returning (index, succeeded, summary line). + let run_one = |index: usize, agent_name: String, tool: RuntimeOrchestrationTool| { + let ctx = self.clone(); + let parent_tool_call_id = parent_tool_call_id.clone(); + let tool_input = tasks[index].to_tool_input(); + async move { + match ctx.resolve_delegation(tool, &tool_input, child_depth).await { + Ok(delegation) => match ctx + .run_child_delegation(delegation, &parent_tool_call_id, child_depth) + .await + { + Ok(result) => ( + index, + true, + format!("[{index}] {agent_name}:\n{}", result.summary), + ), + Err(error) => (index, false, format!("[{index}] {agent_name}: {error}")), + }, + Err(error) => (index, false, format!("[{index}] {agent_name}: {error}")), + } + } + }; + + let mut active = FuturesUnordered::new(); + let mut stop_fast = false; + + // Prime the scheduler up to the concurrency limit. + while active.len() < max_concurrency { + if self.session_abort_signal.is_cancelled() { + break; } + if let Some((index, agent_name, tool)) = queued.pop_front() { + active.push(run_one(index, agent_name, tool)); + } else { + break; + } + } + + while let Some((index, succeeded, line)) = active.next().await { + indexed_summaries.push((index, line)); + + if request.fail_fast && !succeeded { + stop_fast = true; + } + + if stop_fast || self.session_abort_signal.is_cancelled() { + continue; + } + + while active.len() < max_concurrency { + if let Some((next_index, agent_name, tool)) = queued.pop_front() { + active.push(run_one(next_index, agent_name, tool)); + } else { + break; + } + } + } + + // Any tasks never scheduled (due to failFast or cancellation) are + // reported as skipped, preserving their index for ordered output. + let skip_reason = if self.session_abort_signal.is_cancelled() { + "skipped because the parent run was cancelled" + } else { + "skipped because failFast stopped scheduling after an earlier failure" + }; + for (index, agent_name, _tool) in queued { + indexed_summaries.push((index, format!("[{index}] {agent_name}: {skip_reason}"))); } - let summary = summaries.join("\n\n"); + indexed_summaries.sort_by_key(|(index, _)| *index); + let summary = indexed_summaries + .into_iter() + .map(|(_, line)| line) + .collect::>() + .join("\n\n"); + Ok(AgentToolResult { content: vec![ContentBlock::Text(TextContent::new(summary.clone()))], details: Some(serde_json::json!({ "summary": summary })), @@ -979,7 +1056,6 @@ impl HelperDelegationContext { parent_tool_call_id: Some(parent_tool_call_id.to_string()), task: delegation.task, model_role: delegation.model_role, - system_prompt: String::new(), workspace_path: self.workspace_path.clone(), run_mode: self.run_mode.clone(), event_tx: self.event_tx.clone(), diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index fb8b6ee7..42a3ed5a 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -501,6 +501,7 @@ const en: Record = { "settings.agents.canDelegateDesc": "When enabled, this agent will have agent_* tools available to spawn sub-agents for exploration, review, or parallel work.", "settings.agents.maxDelegationDepth": "Maximum delegation depth", "settings.agents.maxDelegationDepthDesc": "The deepest level this agent can be placed at in a delegation chain. Level 1 is the main agent, level 2 is a direct sub-agent, etc. Default: 3.", + "settings.agents.delegationBadge": "Delegates · depth {{depth}}", "settings.agents.unsavedChanges": "Unsaved changes", "settings.agents.unsaved": "Unsaved", "settings.agents.saved": "Saved", diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 6a66afd3..063b74ba 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -519,6 +519,7 @@ const zhCN = { "settings.agents.canDelegateDesc": "启用后,此 Agent 将拥有 agent_* 工具,可生成子 Agent 进行探索、审查或并行工作。", "settings.agents.maxDelegationDepth": "最大被委派深度", "settings.agents.maxDelegationDepthDesc": "此 Agent 在委派链中可处于的最深层级。第 1 层为主 Agent,第 2 层为直接子 Agent,以此类推。默认:3。", + "settings.agents.delegationBadge": "可委派 · 深度 {{depth}}", "settings.agents.unsavedChanges": "有未保存修改", "settings.agents.unsaved": "未保存", "settings.agents.saved": "已保存", diff --git a/src/modules/settings-center/ui/agents-settings-panel.tsx b/src/modules/settings-center/ui/agents-settings-panel.tsx index fd2f67c8..1208c021 100644 --- a/src/modules/settings-center/ui/agents-settings-panel.tsx +++ b/src/modules/settings-center/ui/agents-settings-panel.tsx @@ -269,6 +269,8 @@ export function AgentsSettingsPanel({ allowedTools: editState.allowedTools ?? selectedAgent.allowedTools, modelRole: editState.modelRole ?? selectedAgent.modelRole ?? "auxiliary", isEnabled: editState.isEnabled ?? selectedAgent.isEnabled, + canDelegate: editState.canDelegate ?? selectedAgent.canDelegate, + maxDelegationDepth: editState.maxDelegationDepth ?? selectedAgent.maxDelegationDepth, }; setEditorErrorMessage(null); setIsSaving(true); @@ -683,6 +685,11 @@ export function AgentsSettingsPanel({ {modelRoleLabel(agent.modelRole)} + {agent.canDelegate ? ( + + {t("settings.agents.delegationBadge", { depth: agent.maxDelegationDepth })} + + ) : null} {isSelected && hasUnsavedChanges ? ( {t("settings.agents.unsaved")} From 3f00a5d76db9140e17309b826e0794043fa57c1a Mon Sep 17 00:00:00 2001 From: Jorben Date: Sun, 7 Jun 2026 00:26:38 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat(agents):=20=E2=9C=A8=20add=20delegatio?= =?UTF-8?q?n=20capability=20badges=20and=20improve=20subagent=20cancellati?= =?UTF-8?q?on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract `validate_delegation_capability` as a standalone pure function shared by the recursive subagent path, improving testability - Add unit tests covering all delegation validation edge cases - Move `MAIN_AGENT_CHILD_DEPTH` to module-level constant for reuse - Cancel in-flight subagents on checkpoint to prevent helpers from continuing mid-LLM-turn after abort signal - Extract `SUBAGENT_COLUMNS_PREFIXED` constant to avoid duplicating qualified column lists in JOIN queries - Add DB CHECK constraint tests for delegation field boundary values - Show delegation capability badges in built-in agent settings UI - Add i18n keys for "No delegation" badge label --- src-tauri/src/core/agent_session_execution.rs | 15 +- src-tauri/src/core/subagent/orchestrator.rs | 130 +++++++++++++++--- .../persistence/repo/custom_subagent_repo.rs | 7 +- src-tauri/tests/custom_subagent.rs | 50 +++++++ src/i18n/locales/en.ts | 1 + src/i18n/locales/zh-CN.ts | 1 + .../ui/agents-settings-panel.tsx | 17 +++ 7 files changed, 199 insertions(+), 22 deletions(-) diff --git a/src-tauri/src/core/agent_session_execution.rs b/src-tauri/src/core/agent_session_execution.rs index 375cc493..e6a2b913 100644 --- a/src-tauri/src/core/agent_session_execution.rs +++ b/src-tauri/src/core/agent_session_execution.rs @@ -49,6 +49,11 @@ struct ResolvedHelperDelegate { model_role: ResolvedModelRole, } +/// Delegation chain depth (1-based) at which the main agent's direct delegates +/// run. The main agent is depth 1, so its immediate sub-agents are depth 2. +/// Shared by the resolve-time depth check and the `HelperRunRequest` it builds. +const MAIN_AGENT_CHILD_DEPTH: u32 = 2; + fn resolve_helper_tool_task( tool: RuntimeOrchestrationTool, tool_input: &serde_json::Value, @@ -909,7 +914,6 @@ impl AgentSession { // Reject targets whose configured max delegation depth cannot accommodate // being delegated to at depth 2 (mirrors the recursive subagent path's // enforcement in HelperDelegationContext::resolve_delegation_sync). - const MAIN_AGENT_CHILD_DEPTH: u32 = 2; if let Some(profile) = resolved_profile.as_ref() { let max_depth = profile.max_delegation_depth(); if MAIN_AGENT_CHILD_DEPTH > max_depth { @@ -955,8 +959,7 @@ impl AgentSession { event_tx: self.event_tx.clone(), session_abort_signal: self.abort_signal.clone(), thinking_level: self.spec.model_plan.thinking_level, - // The main agent is depth 1; its direct delegates are depth 2. - delegation_depth: 2, + delegation_depth: MAIN_AGENT_CHILD_DEPTH, model_plan: self.spec.model_plan.clone(), custom_delegation_targets, }) @@ -1107,7 +1110,13 @@ impl AgentSession { } self.checkpoint_requested.store(true, Ordering::SeqCst); + // Mirror AgentSession::cancel ordering: cancel in-flight tool calls, + // abort any in-flight subagents for this run (so a helper mid-LLM-turn + // stops rather than only exiting on its next poll), then abort the main + // agent. abort_signal child tokens already cascade into tool executions, + // but cancel_run is needed to stop subagent Agent run loops promptly. self.abort_signal.cancel(); + self.helper_orchestrator.cancel_run(&self.spec.run_id).await; self.agent.abort(); let result_message = match &plan_file_path { diff --git a/src-tauri/src/core/subagent/orchestrator.rs b/src-tauri/src/core/subagent/orchestrator.rs index a3f3b0de..74fc5c86 100644 --- a/src-tauri/src/core/subagent/orchestrator.rs +++ b/src-tauri/src/core/subagent/orchestrator.rs @@ -122,6 +122,40 @@ pub(crate) fn resolve_delegation_task( Ok((task, None)) } +/// Validate that `caller_profile` may delegate to `target_profile` at +/// `child_depth` (the 1-based depth the child would occupy). Enforces three +/// bounds: the caller must be allowed to delegate, the child depth must not +/// exceed the global safety maximum, and the child depth must not exceed the +/// target's configured `max_delegation_depth`. Returns a structured error +/// string on rejection. Pure (no I/O), so it is shared by the recursive +/// subagent path and is directly unit-testable. +pub(crate) fn validate_delegation_capability( + caller_profile: &SubagentProfile, + target_tool: &RuntimeOrchestrationTool, + target_profile: &SubagentProfile, + child_depth: u32, +) -> Result<(), String> { + if !caller_profile.can_delegate() { + return Err(format!( + "{} is not allowed to delegate to other subagents", + caller_profile.helper_kind() + )); + } + if child_depth > GLOBAL_MAX_DELEGATION_DEPTH { + return Err(format!( + "delegation depth {child_depth} exceeds the global maximum {GLOBAL_MAX_DELEGATION_DEPTH}" + )); + } + if child_depth > target_profile.max_delegation_depth() { + return Err(format!( + "{} cannot be delegated at depth {child_depth} (its max delegation depth is {})", + target_tool.tool_name(), + target_profile.max_delegation_depth() + )); + } + Ok(()) +} + #[derive(Debug, Clone, Copy, Serialize)] #[serde(rename_all = "snake_case")] pub enum SubagentActivityStatus { @@ -969,24 +1003,7 @@ impl HelperDelegationContext { tool_input: &serde_json::Value, child_depth: u32, ) -> Result { - if !self.caller_profile.can_delegate() { - return Err(format!( - "{} is not allowed to delegate to other subagents", - self.caller_profile.helper_kind() - )); - } - if child_depth > GLOBAL_MAX_DELEGATION_DEPTH { - return Err(format!( - "delegation depth {child_depth} exceeds the global maximum {GLOBAL_MAX_DELEGATION_DEPTH}" - )); - } - if child_depth > profile.max_delegation_depth() { - return Err(format!( - "{} cannot be delegated at depth {child_depth} (its max delegation depth is {})", - tool.tool_name(), - profile.max_delegation_depth() - )); - } + validate_delegation_capability(&self.caller_profile, &tool, &profile, child_depth)?; let (task, _review_request) = resolve_delegation_task(&tool, tool_input)?; let model_role = crate::core::agent_session_tools::resolve_helper_model_role( @@ -1759,4 +1776,81 @@ mod tests { "subagent prompt must not contain BehavioralGuidelines section" ); } + + fn custom_profile(can_delegate: bool, max_delegation_depth: u32) -> SubagentProfile { + SubagentProfile::Custom { + slug: "deep".to_string(), + name: "Deep".to_string(), + invocation_description: "deep agent".to_string(), + system_prompt: "prompt".to_string(), + allowed_tools: vec!["read".to_string()], + model_role: crate::model::subagent::CustomSubagentModelRole::Auxiliary, + can_delegate, + max_delegation_depth, + } + } + + #[test] + fn validate_delegation_rejects_caller_that_cannot_delegate() { + // explore.can_delegate() == false, so it may never delegate regardless of depth. + let err = validate_delegation_capability( + &SubagentProfile::Explore, + &RuntimeOrchestrationTool::Review, + &SubagentProfile::Review, + 2, + ) + .expect_err("explore must not be allowed to delegate"); + assert!(err.contains("not allowed to delegate")); + } + + #[test] + fn validate_delegation_allows_review_to_explore_at_depth_2() { + // Main(1) → review(2): review can delegate, explore.max=3 >= 2. + validate_delegation_capability( + &SubagentProfile::Review, + &RuntimeOrchestrationTool::Explore, + &SubagentProfile::Explore, + 2, + ) + .expect("review delegating to explore at depth 2 must be allowed"); + } + + #[test] + fn validate_delegation_rejects_when_child_depth_exceeds_target_max() { + // child_depth 4 exceeds explore.max_delegation_depth (3). + let err = validate_delegation_capability( + &SubagentProfile::Review, + &RuntimeOrchestrationTool::Explore, + &SubagentProfile::Explore, + 4, + ) + .expect_err("depth 4 must exceed explore max depth 3"); + assert!(err.contains("max delegation depth is 3")); + } + + #[test] + fn validate_delegation_rejects_when_child_depth_exceeds_global_max() { + // Even a custom target with max=5 cannot be reached beyond the global bound. + let target = custom_profile(true, 5); + let err = validate_delegation_capability( + &SubagentProfile::Review, + &RuntimeOrchestrationTool::Custom("deep".to_string()), + &target, + GLOBAL_MAX_DELEGATION_DEPTH + 1, + ) + .expect_err("depth beyond global max must be rejected"); + assert!(err.contains("global maximum")); + } + + #[test] + fn validate_delegation_allows_custom_target_within_its_configured_depth() { + let target = custom_profile(true, 5); + validate_delegation_capability( + &SubagentProfile::Review, + &RuntimeOrchestrationTool::Custom("deep".to_string()), + &target, + 5, + ) + .expect("custom target with max depth 5 must be reachable at depth 5"); + } } diff --git a/src-tauri/src/persistence/repo/custom_subagent_repo.rs b/src-tauri/src/persistence/repo/custom_subagent_repo.rs index 379ad9ba..df16203e 100644 --- a/src-tauri/src/persistence/repo/custom_subagent_repo.rs +++ b/src-tauri/src/persistence/repo/custom_subagent_repo.rs @@ -46,6 +46,11 @@ impl SubagentRow { const SUBAGENT_COLUMNS: &str = "id, name, slug, system_prompt, invocation_description, allowed_tools, model_role, is_enabled, can_delegate, max_delegation_depth, created_at, updated_at"; +/// Same column list as `SUBAGENT_COLUMNS` but qualified with the `s.` table +/// alias, for queries that JOIN `custom_subagents AS s`. Kept beside the base +/// constant so adding a column only requires updating both in one place. +const SUBAGENT_COLUMNS_PREFIXED: &str = "s.id, s.name, s.slug, s.system_prompt, s.invocation_description, s.allowed_tools, s.model_role, s.is_enabled, s.can_delegate, s.max_delegation_depth, s.created_at, s.updated_at"; + // --------------------------------------------------------------------------- // CRUD operations // --------------------------------------------------------------------------- @@ -306,7 +311,7 @@ pub async fn list_for_profile( profile_id: &str, ) -> Result, AppError> { let rows = sqlx::query_as::<_, SubagentRow>(&format!( - "SELECT s.id, s.name, s.slug, s.system_prompt, s.invocation_description, s.allowed_tools, s.model_role, s.is_enabled, s.can_delegate, s.max_delegation_depth, s.created_at, s.updated_at \ + "SELECT {SUBAGENT_COLUMNS_PREFIXED} \ FROM custom_subagents s \ INNER JOIN profile_subagent_access a ON s.id = a.subagent_id \ WHERE a.profile_id = ? AND s.is_enabled = 1 \ diff --git a/src-tauri/tests/custom_subagent.rs b/src-tauri/tests/custom_subagent.rs index a3e9fc0b..83d1f34e 100644 --- a/src-tauri/tests/custom_subagent.rs +++ b/src-tauri/tests/custom_subagent.rs @@ -366,3 +366,53 @@ async fn custom_subagent_delegation_fields_persist_and_clamp() { assert!(!updated.can_delegate); assert_eq!(updated.max_delegation_depth, 1, "depth must clamp to 1"); } + +#[tokio::test] +async fn db_check_rejects_out_of_range_delegation_values() { + // The repo layer clamps inputs, so the DB CHECK constraints are normally + // unreachable through the public API. This test bypasses the repo with raw + // SQL to assert the schema-level guards still reject illegal values, guarding + // against future code paths that write the columns directly. + let pool = setup_test_pool().await; + + let insert = |id: &str, can_delegate: i64, depth: i64| { + let sql = "INSERT INTO custom_subagents \ + (id, name, slug, system_prompt, invocation_description, allowed_tools, model_role, is_enabled, can_delegate, max_delegation_depth, created_at, updated_at) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; + sqlx::query(sql) + .bind(id.to_string()) + .bind("Raw") + .bind(id.to_string()) + .bind("prompt") + .bind("desc") + .bind("[]") + .bind("auxiliary") + .bind(1_i64) + .bind(can_delegate) + .bind(depth) + .bind("now") + .bind("now") + .execute(&pool) + }; + + // depth = 0 violates max_delegation_depth >= 1. + assert!( + insert("too-low", 0, 0).await.is_err(), + "DB CHECK must reject max_delegation_depth = 0" + ); + // depth = 6 violates max_delegation_depth <= 5. + assert!( + insert("too-high", 0, 6).await.is_err(), + "DB CHECK must reject max_delegation_depth = 6" + ); + // can_delegate = 2 violates can_delegate IN (0, 1). + assert!( + insert("bad-flag", 2, 3).await.is_err(), + "DB CHECK must reject can_delegate = 2" + ); + // A legal row still inserts. + assert!( + insert("ok-row", 1, 5).await.is_ok(), + "valid delegation values must insert successfully" + ); +} diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 42a3ed5a..9dd01685 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -502,6 +502,7 @@ const en: Record = { "settings.agents.maxDelegationDepth": "Maximum delegation depth", "settings.agents.maxDelegationDepthDesc": "The deepest level this agent can be placed at in a delegation chain. Level 1 is the main agent, level 2 is a direct sub-agent, etc. Default: 3.", "settings.agents.delegationBadge": "Delegates · depth {{depth}}", + "settings.agents.cannotDelegateBadge": "No delegation", "settings.agents.unsavedChanges": "Unsaved changes", "settings.agents.unsaved": "Unsaved", "settings.agents.saved": "Saved", diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 063b74ba..5b3272d2 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -520,6 +520,7 @@ const zhCN = { "settings.agents.maxDelegationDepth": "最大被委派深度", "settings.agents.maxDelegationDepthDesc": "此 Agent 在委派链中可处于的最深层级。第 1 层为主 Agent,第 2 层为直接子 Agent,以此类推。默认:3。", "settings.agents.delegationBadge": "可委派 · 深度 {{depth}}", + "settings.agents.cannotDelegateBadge": "不可委派", "settings.agents.unsavedChanges": "有未保存修改", "settings.agents.unsaved": "未保存", "settings.agents.saved": "已保存", diff --git a/src/modules/settings-center/ui/agents-settings-panel.tsx b/src/modules/settings-center/ui/agents-settings-panel.tsx index 1208c021..302d20a4 100644 --- a/src/modules/settings-center/ui/agents-settings-panel.tsx +++ b/src/modules/settings-center/ui/agents-settings-panel.tsx @@ -66,16 +66,22 @@ const BUILT_IN_AGENTS: Array<{ nameKey: TranslationKey; descriptionKey: TranslationKey; icon: typeof FileSearch; + canDelegate: boolean; + maxDelegationDepth: number; }> = [ { nameKey: "settings.agents.builtIn.explore.name", descriptionKey: "settings.agents.builtIn.explore.desc", icon: FileSearch, + canDelegate: false, + maxDelegationDepth: 3, }, { nameKey: "settings.agents.builtIn.review.name", descriptionKey: "settings.agents.builtIn.review.desc", icon: CheckCircle2, + canDelegate: true, + maxDelegationDepth: 3, }, ]; @@ -617,6 +623,17 @@ export function AgentsSettingsPanel({

{t(agent.descriptionKey)}

+
+ {agent.canDelegate ? ( + + {t("settings.agents.delegationBadge", { depth: agent.maxDelegationDepth })} + + ) : ( + + {t("settings.agents.cannotDelegateBadge")} + + )} +
); })} From 985689a3bbc5a52d594f47e7d5dc557131cd4259 Mon Sep 17 00:00:00 2001 From: Jorben Date: Sun, 7 Jun 2026 09:23:33 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(subagent):=20=F0=9F=90=9B=20prevent=20h?= =?UTF-8?q?elpers=20stuck=20at=20running=20on=20run=20cancellation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a run was cancelled, in-flight helper agents could remain stuck at `running` in the database because their cleanup futures were dropped before they could write a terminal status. Additionally, concurrent cancellation and normal completion could emit duplicate `SubagentFailed` events. - Add `RegisteredHelper` struct to retain metadata needed for synthetic terminal event synthesis during cancellation - Check `session_abort_signal` before inserting a `running` row to avoid spawning helpers for already-cancelled sessions - Handle the race between DB insert and cancellation registration by proactively marking and emitting `SubagentFailed` - Make `mark_failed` return `bool` and guard against overwriting terminal states, preventing duplicate event emission - Add `mark_interrupted_if_active` for safe proactive interruption during `cancel_run` - Replace native ` +
+ + {t("settings.agents.maxDelegationDepth")} + + + {t("settings.agents.maxDelegationDepthDesc")} + +
+ - - {t("settings.agents.maxDelegationDepthDesc")} - - + + + + + {[1, 2, 3, 4, 5].map((depth) => ( + + {depth} + + ))} + + +