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..e6a2b913 100644 --- a/src-tauri/src/core/agent_session_execution.rs +++ b/src-tauri/src/core/agent_session_execution.rs @@ -49,32 +49,22 @@ 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, ) -> 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, }) } @@ -920,6 +910,20 @@ 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). + 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 +939,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(), @@ -944,12 +954,14 @@ 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(), session_abort_signal: self.abort_signal.clone(), thinking_level: self.spec.model_plan.thinking_level, + delegation_depth: MAIN_AGENT_CHILD_DEPTH, + model_plan: self.spec.model_plan.clone(), + custom_delegation_targets, }) .await } @@ -1005,44 +1017,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 { @@ -1132,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 { @@ -2096,9 +2080,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..3641e7dd 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, }; @@ -32,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, @@ -44,6 +47,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 +67,95 @@ 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. 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> { + 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)) +} + +/// 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 { @@ -69,13 +173,26 @@ pub struct SubagentProgressSnapshot { pub recent_actions: Vec, } +/// A single registered helper agent plus the metadata needed to synthesize a +/// terminal `SubagentFailed` event and DB status if the run is cancelled while +/// this helper is still in flight (its own cleanup future may be dropped). +struct RegisteredHelper { + agent: Arc, + helper_id: String, + helper_kind: String, + started_at: String, + event_tx: tokio::sync::mpsc::UnboundedSender, + progress_state: Arc>, +} + /// Tracks active helper agents for a single run, with a cancellation guard /// that prevents new helpers from registering after `cancel_run` has fired. struct RunHelpersState { - helpers: Vec>, + helpers: Vec, cancelled: bool, } +#[derive(Clone)] pub struct HelperAgentOrchestrator { pool: SqlitePool, tool_gateway: Arc, @@ -105,6 +222,18 @@ impl HelperAgentOrchestrator { } pub async fn run_helper(&self, request: HelperRunRequest) -> Result { + // Fast pre-check: if the parent session was already cancelled, do not + // even insert a `running` row or emit SubagentStarted. The shared + // `session_abort_signal` is durable (it stays cancelled once fired), + // unlike the per-run `active_helpers` entry which is torn down after + // `cancel_run`. This prevents a cancelled run from spawning helpers that + // would otherwise linger at `running` in the DB / event stream. + if request.session_abort_signal.is_cancelled() { + return Err(AppError::internal( + ErrorSource::Thread, + "run cancelled".to_string(), + )); + } let helper_profile = match request.helper_profile.or_else(|| request.tool.profile()) { Some(p) => p, None => { @@ -169,7 +298,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 +362,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 +405,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, @@ -438,12 +623,44 @@ impl HelperAgentOrchestrator { cancelled: false, }); if state.cancelled { + // The run was cancelled between our DB insert / SubagentStarted + // emission above and this registration point. We must not leave + // this helper stuck at `running`: write a terminal status and + // emit SubagentFailed so both the DB snapshot and the live event + // stream converge on a terminal state. + drop(helpers); + let snapshot = snapshot_from_progress(&progress_state); + let error_message = "Subagent cancelled before it started running."; + let newly_marked = run_helper_repo::mark_interrupted_if_active( + &self.pool, + &helper_id, + error_message, + ) + .await + .unwrap_or(false); + if newly_marked { + let _ = request.event_tx.send(ThreadStreamEvent::SubagentFailed { + run_id: request.run_id.clone(), + subtask_id: helper_id.clone(), + helper_kind: resolved_helper_kind.clone(), + started_at: helper_started_at.clone(), + error: error_message.to_string(), + snapshot, + }); + } return Err(AppError::internal( ErrorSource::Thread, "run cancelled".to_string(), )); } - state.helpers.push(Arc::clone(&agent)); + state.helpers.push(RegisteredHelper { + agent: Arc::clone(&agent), + helper_id: helper_id.clone(), + helper_kind: resolved_helper_kind.clone(), + started_at: helper_started_at.clone(), + event_tx: request.event_tx.clone(), + progress_state: Arc::clone(&progress_state), + }); } let helper_run_id_for_usage = request.run_id.clone(); @@ -521,7 +738,11 @@ impl HelperAgentOrchestrator { let interrupted = error.to_string().to_lowercase().contains("aborted"); let usage = Usage::default(); let snapshot = snapshot_from_progress(&progress_state); - run_helper_repo::mark_failed( + // Only emit SubagentFailed when this call actually transitioned + // the helper to a terminal state. If a concurrent `cancel_run` + // already flipped it to `interrupted`, `mark_failed` is a no-op + // and we skip the event to avoid double-reporting the helper. + let newly_marked = run_helper_repo::mark_failed( &self.pool, &helper_id, &error.to_string(), @@ -530,14 +751,16 @@ impl HelperAgentOrchestrator { ) .await?; - let _ = request.event_tx.send(ThreadStreamEvent::SubagentFailed { - run_id: request.run_id, - subtask_id: helper_id, - helper_kind: resolved_helper_kind, - started_at: helper_started_at, - error: error.to_string(), - snapshot, - }); + if newly_marked { + let _ = request.event_tx.send(ThreadStreamEvent::SubagentFailed { + run_id: request.run_id, + subtask_id: helper_id, + helper_kind: resolved_helper_kind, + started_at: helper_started_at, + error: error.to_string(), + snapshot, + }); + } Err(AppError::internal( ErrorSource::Thread, @@ -547,6 +770,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; @@ -559,7 +796,37 @@ impl HelperAgentOrchestrator { }; for helper in helpers { - helper.abort(); + // Abort the underlying agent so its run loop / LLM streaming stops. + helper.agent.abort(); + + // The aborted helper's own `run_helper` cleanup (mark_failed + + // SubagentFailed emission) normally runs once `agent.prompt()` + // returns. But during a live cancel the surrounding tool future can + // be dropped before that cleanup executes, leaving the helper stuck + // at `running`. Proactively converge it on a terminal state here. + let error_message = "Subagent interrupted because the run was cancelled."; + let marked = run_helper_repo::mark_interrupted_if_active( + &self.pool, + &helper.helper_id, + error_message, + ) + .await + .unwrap_or(false); + + // Only emit the synthetic terminal event when we actually flipped + // the DB status, so we never double-report a helper that finished + // cleanly in the same instant cancellation fired. + if marked { + let snapshot = snapshot_from_progress(&helper.progress_state); + let _ = helper.event_tx.send(ThreadStreamEvent::SubagentFailed { + run_id: run_id.to_string(), + subtask_id: helper.helper_id.clone(), + helper_kind: helper.helper_kind.clone(), + started_at: helper.started_at.clone(), + error: error_message.to_string(), + snapshot, + }); + } } } @@ -568,7 +835,7 @@ impl HelperAgentOrchestrator { if let Some(state) = active.get_mut(run_id) { state .helpers - .retain(|candidate| !Arc::ptr_eq(candidate, helper)); + .retain(|candidate| !Arc::ptr_eq(&candidate.agent, helper)); if state.helpers.is_empty() { active.remove(run_id); } @@ -576,6 +843,342 @@ 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. 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 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 { + indexed_summaries.push(( + index, + format!("[{index}] {agent_name}: unknown subagent tool"), + )); + continue; + }; + if tool == RuntimeOrchestrationTool::Parallel { + indexed_summaries.push(( + index, + format!("[{index}] {agent_name}: agent_parallel cannot delegate to itself"), + )); + continue; + } + queued.push_back((index, agent_name, tool)); + } + + 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}"))); + } + + 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 })), + }) + } + + /// 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 { + 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( + &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, + 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 +1770,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 +1796,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( @@ -1257,4 +1868,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/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..df16203e 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,20 @@ 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"; + +/// 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 @@ -102,11 +111,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 +132,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 +159,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 +188,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 +207,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 +235,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 +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.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/src/persistence/repo/run_helper_repo.rs b/src-tauri/src/persistence/repo/run_helper_repo.rs index 1fed6bdf..0eb59481 100644 --- a/src-tauri/src/persistence/repo/run_helper_repo.rs +++ b/src-tauri/src/persistence/repo/run_helper_repo.rs @@ -123,16 +123,22 @@ pub async fn mark_failed( error_summary: &str, interrupted: bool, usage: &Usage, -) -> Result<(), AppError> { +) -> Result { let now = Utc::now().to_rfc3339(); let status = if interrupted { "interrupted" } else { "failed" }; - sqlx::query( + // Guard against overwriting a helper that already reached a terminal state + // (e.g. when live run cancellation already flipped it to `interrupted` via + // `mark_interrupted_if_active`). Returning whether the row was actually + // updated lets callers avoid emitting a duplicate `SubagentFailed` event. + let result = sqlx::query( "UPDATE run_helpers SET status = ?, error_summary = ?, finished_at = ?, input_tokens = ?, output_tokens = ?, cache_read_tokens = ?, cache_write_tokens = ?, total_tokens = ? - WHERE id = ?", + WHERE id = ? + AND status NOT IN ('completed', 'failed', 'interrupted', 'cancelled') + AND finished_at IS NULL", ) .bind(status) .bind(error_summary) @@ -146,7 +152,37 @@ pub async fn mark_failed( .execute(pool) .await?; - Ok(()) + Ok(result.rows_affected() > 0) +} + +/// Mark a single still-active helper as interrupted, but only when it has not +/// already reached a terminal state. Used during live run cancellation so that +/// in-flight helpers whose own cleanup future may be dropped still land on a +/// terminal DB status (instead of being stuck at `running` until restart). The +/// `WHERE status NOT IN (...)` guard makes this safe against a helper that just +/// completed in the same instant cancellation fired. +pub async fn mark_interrupted_if_active( + pool: &SqlitePool, + id: &str, + error_summary: &str, +) -> Result { + let now = Utc::now().to_rfc3339(); + let result = sqlx::query( + "UPDATE run_helpers + SET status = 'interrupted', + error_summary = COALESCE(error_summary, ?), + finished_at = ? + WHERE id = ? + AND status NOT IN ('completed', 'failed', 'interrupted', 'cancelled') + AND finished_at IS NULL", + ) + .bind(error_summary) + .bind(&now) + .bind(id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) } /// Mark all non-terminal run helpers as interrupted (crash recovery). @@ -367,9 +403,10 @@ mod tests { insert(&pool, &helper).await.unwrap(); let usage = default_usage(); - mark_failed(&pool, "h-1", "Something broke", false, &usage) + let marked = mark_failed(&pool, "h-1", "Something broke", false, &usage) .await .unwrap(); + assert!(marked, "running helper should transition to failed"); let row = sqlx::query( "SELECT status, error_summary, finished_at FROM run_helpers WHERE id = 'h-1'", @@ -416,6 +453,47 @@ mod tests { assert_eq!(status, "interrupted"); } + #[tokio::test] + async fn mark_failed_is_noop_when_helper_already_terminal() { + let pool = setup_test_pool().await; + let helper = RunHelperInsert { + id: "h-1".into(), + run_id: "run-1".into(), + thread_id: "t1".into(), + helper_kind: "explore".into(), + parent_tool_call_id: None, + status: "running".into(), + model_role: "auxiliary".into(), + provider_id: None, + model_id: None, + input_summary: None, + }; + insert(&pool, &helper).await.unwrap(); + + // Simulate live cancellation flipping the helper to interrupted first. + let first = mark_interrupted_if_active(&pool, "h-1", "cancelled by run") + .await + .unwrap(); + assert!(first); + + // A subsequent mark_failed from the aborted helper's own cleanup must be + // a no-op and must not overwrite the cancellation error summary. + let usage = default_usage(); + let marked = mark_failed(&pool, "h-1", "aborted", true, &usage) + .await + .unwrap(); + assert!(!marked, "already-terminal helper must not be re-marked"); + + let row = sqlx::query("SELECT status, error_summary FROM run_helpers WHERE id = 'h-1'") + .fetch_one(&pool) + .await + .unwrap(); + let status: String = row.get(0); + let error: String = row.get(1); + assert_eq!(status, "interrupted"); + assert_eq!(error, "cancelled by run"); + } + #[tokio::test] async fn interrupt_active_helpers_only_affects_non_terminal() { let pool = setup_test_pool().await; @@ -471,6 +549,120 @@ mod tests { } } + #[tokio::test] + async fn mark_interrupted_if_active_flips_running_helper() { + let pool = setup_test_pool().await; + let helper = RunHelperInsert { + id: "h-1".into(), + run_id: "run-1".into(), + thread_id: "t1".into(), + helper_kind: "explore".into(), + parent_tool_call_id: None, + status: "running".into(), + model_role: "auxiliary".into(), + provider_id: None, + model_id: None, + input_summary: None, + }; + insert(&pool, &helper).await.unwrap(); + + let marked = mark_interrupted_if_active(&pool, "h-1", "cancelled mid-flight") + .await + .unwrap(); + assert!(marked, "running helper should be flipped to interrupted"); + + let row = sqlx::query( + "SELECT status, error_summary, finished_at FROM run_helpers WHERE id = 'h-1'", + ) + .fetch_one(&pool) + .await + .unwrap(); + let status: String = row.get(0); + let error: String = row.get(1); + let finished: Option = row.get(2); + assert_eq!(status, "interrupted"); + assert_eq!(error, "cancelled mid-flight"); + assert!(finished.is_some()); + } + + #[tokio::test] + async fn mark_interrupted_if_active_is_noop_for_terminal_helpers() { + let pool = setup_test_pool().await; + for (id, status) in &[ + ("h-completed", "completed"), + ("h-failed", "failed"), + ("h-interrupted", "interrupted"), + ("h-cancelled", "cancelled"), + ] { + let helper = RunHelperInsert { + id: id.to_string(), + run_id: "run-1".into(), + thread_id: "t1".into(), + helper_kind: "review".into(), + parent_tool_call_id: None, + status: status.to_string(), + model_role: "auxiliary".into(), + provider_id: None, + model_id: None, + input_summary: None, + }; + insert(&pool, &helper).await.unwrap(); + } + + for (id, expected) in [ + ("h-completed", "completed"), + ("h-failed", "failed"), + ("h-interrupted", "interrupted"), + ("h-cancelled", "cancelled"), + ] { + let marked = mark_interrupted_if_active(&pool, id, "should not apply") + .await + .unwrap(); + assert!(!marked, "{id} is terminal and must not be re-marked"); + let status: String = + sqlx::query(&format!("SELECT status FROM run_helpers WHERE id = '{id}'")) + .fetch_one(&pool) + .await + .unwrap() + .get(0); + assert_eq!(status, expected, "{id} status should remain '{expected}'"); + } + } + + #[tokio::test] + async fn mark_interrupted_if_active_preserves_existing_error_summary() { + let pool = setup_test_pool().await; + let helper = RunHelperInsert { + id: "h-1".into(), + run_id: "run-1".into(), + thread_id: "t1".into(), + helper_kind: "explore".into(), + parent_tool_call_id: None, + status: "running".into(), + model_role: "auxiliary".into(), + provider_id: None, + model_id: None, + input_summary: None, + }; + insert(&pool, &helper).await.unwrap(); + sqlx::query("UPDATE run_helpers SET error_summary = 'original detail' WHERE id = 'h-1'") + .execute(&pool) + .await + .unwrap(); + + let marked = mark_interrupted_if_active(&pool, "h-1", "fallback message") + .await + .unwrap(); + assert!(marked); + + let error: String = sqlx::query("SELECT error_summary FROM run_helpers WHERE id = 'h-1'") + .fetch_one(&pool) + .await + .unwrap() + .get(0); + assert_eq!(error, "original detail"); + } + #[tokio::test] async fn list_by_run_ids_returns_helpers_for_given_runs() { let pool = setup_test_pool().await; diff --git a/src-tauri/tests/custom_subagent.rs b/src-tauri/tests/custom_subagent.rs index 0f5d48f9..83d1f34e 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,122 @@ 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"); +} + +#[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 784e7e68..9dd01685 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -495,6 +495,14 @@ 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.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 56139f90..5b3272d2 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -513,6 +513,14 @@ 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.delegationBadge": "可委派 · 深度 {{depth}}", + "settings.agents.cannotDelegateBadge": "不可委派", "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..7056a2cf 100644 --- a/src/modules/settings-center/ui/agents-settings-panel.tsx +++ b/src/modules/settings-center/ui/agents-settings-panel.tsx @@ -38,6 +38,13 @@ import { Input } from "@/shared/ui/input"; import { Separator } from "@/shared/ui/separator"; import { Switch } from "@/shared/ui/switch"; import { Textarea } from "@/shared/ui/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/ui/select"; const TOOL_CATEGORIES: Array<{ labelKey: TranslationKey; tools: string[] }> = [ { @@ -66,16 +73,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, }, ]; @@ -179,6 +192,8 @@ export function AgentsSettingsPanel({ allowedTools: ["read", "list", "search", "find"], modelRole: "auxiliary", isEnabled: true, + canDelegate: false, + maxDelegationDepth: 3, }; try { const created = await customSubagentCreate(input); @@ -267,6 +282,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); @@ -446,6 +463,59 @@ export function AgentsSettingsPanel({ + +
+ + updateField("canDelegate", checked)} + /> +
+
+
+ + {t("settings.agents.maxDelegationDepth")} + + + {t("settings.agents.maxDelegationDepthDesc")} + +
+ +
+
+ + +

{t(agent.descriptionKey)}

+
+ {agent.canDelegate ? ( + + {t("settings.agents.delegationBadge", { depth: agent.maxDelegationDepth })} + + ) : ( + + {t("settings.agents.cannotDelegateBadge")} + + )} +
); })} @@ -637,6 +718,11 @@ export function AgentsSettingsPanel({ {modelRoleLabel(agent.modelRole)} + {agent.canDelegate ? ( + + {t("settings.agents.delegationBadge", { depth: agent.maxDelegationDepth })} + + ) : null} {isSelected && hasUnsavedChanges ? ( {t("settings.agents.unsaved")} diff --git a/src/services/bridge/subagent-commands.ts b/src/services/bridge/subagent-commands.ts index 623a212e..9c2667f2 100644 --- a/src/services/bridge/subagent-commands.ts +++ b/src/services/bridge/subagent-commands.ts @@ -17,6 +17,8 @@ export type CustomSubagentInput = { allowedTools: string[]; modelRole?: CustomSubagentModelRole; isEnabled?: boolean; + canDelegate?: boolean; + maxDelegationDepth?: number; }; export async function customSubagentList(): Promise {