diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index dd23cff4b..9f69679a1 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -3496,6 +3496,9 @@ fn is_executable_file(path: &Path) -> bool { #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] struct CompanionFeatureFlags { delegation: bool, + /// When true (with delegation), the tool description is @-mention only. + /// Unknown to older companions (ignored). + mention_only: bool, feedback: bool, ask: bool, sessions: bool, @@ -3518,6 +3521,9 @@ fn companion_features_arg(flags: CompanionFeatureFlags) -> Option { let mut features: Vec<&str> = Vec::new(); if flags.delegation { features.push("delegation"); + if flags.mention_only { + features.push("mention_only"); + } } if flags.feedback { features.push("feedback"); @@ -3592,6 +3598,7 @@ async fn inject_codeg_mcp( } let flags = CompanionFeatureFlags { delegation: delegation_enabled, + mention_only: delegation_enabled && !injection.broker.allow_self_initiate(), feedback: feedback_enabled, ask: injection.ask.is_enabled().await, sessions: injection.sessions.is_enabled().await, @@ -15951,6 +15958,7 @@ mod tests { assert_eq!( companion_features_arg(CompanionFeatureFlags { delegation: true, + mention_only: false, feedback: true, ask: true, sessions: true, @@ -15960,6 +15968,13 @@ mod tests { }), Some("delegation,feedback,ask,sessions,tasks,automations,taskboard".to_string()) ); + assert_eq!( + only(|f| { + f.delegation = true; + f.mention_only = true; + }), + Some("delegation,mention_only".to_string()) + ); } // ── Boolean config options (cline 3.0.50 `auto_approve`) ── diff --git a/src-tauri/src/acp/delegation/broker.rs b/src-tauri/src/acp/delegation/broker.rs index 6c428f34b..3d078451b 100644 --- a/src-tauri/src/acp/delegation/broker.rs +++ b/src-tauri/src/acp/delegation/broker.rs @@ -49,6 +49,7 @@ //! children — they keep running in the background (the whole point of async). use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -1255,6 +1256,10 @@ pub struct DelegationBroker { tool_calls: Arc, pre_canceled_handles: Arc, config: Arc>, + /// Separate from `DelegationConfig` so existing test literals do not + /// have to name it. Default on: agents may spawn without `@`. The + /// settings toggle can turn it off (mention-only description). + allow_self_initiate: Arc, /// Woken after every terminal `record_completed` so a `get_delegation_status` /// long-poll wakes the instant its task finishes instead of busy-polling. result_notify: Arc, @@ -1316,6 +1321,7 @@ impl DelegationBroker { tool_calls: Arc::new(ToolCallTracker::default()), pre_canceled_handles: Arc::new(PreCanceledHandles::default()), config: Arc::new(Mutex::new(DelegationConfig::default())), + allow_self_initiate: Arc::new(AtomicBool::new(true)), result_notify: Arc::new(Notify::new()), block_resurface: BLOCK_RESURFACE_INTERVAL, } @@ -2076,6 +2082,14 @@ impl DelegationBroker { self.config.lock().await.clone() } + pub fn set_allow_self_initiate(&self, allow: bool) { + self.allow_self_initiate.store(allow, Ordering::Relaxed); + } + + pub fn allow_self_initiate(&self) -> bool { + self.allow_self_initiate.load(Ordering::Relaxed) + } + /// If this in-flight setup has been flagged canceled by a parent cancel, /// deregister it and return true. One lock acquisition; used at the /// pre-spawn / post-spawn checkpoints in `handle_request`. diff --git a/src-tauri/src/acp/delegation/companion.rs b/src-tauri/src/acp/delegation/companion.rs index 8cf8eb0de..08c269ea2 100644 --- a/src-tauri/src/acp/delegation/companion.rs +++ b/src-tauri/src/acp/delegation/companion.rs @@ -152,6 +152,9 @@ pub struct CompanionFeatures { pub automations: bool, /// `create_work_task` — queue a card on the work-task board from chat. pub taskboard: bool, + /// When true (with delegation), rewrite `delegate_to_agent` so only an + /// `@` mention starts a child. Unknown token on older parents: ignored. + pub mention_only: bool, } impl CompanionFeatures { @@ -171,6 +174,7 @@ impl CompanionFeatures { tasks: false, automations: false, taskboard: false, + mention_only: false, }; }; let mut f = Self { @@ -181,6 +185,7 @@ impl CompanionFeatures { tasks: false, automations: false, taskboard: false, + mention_only: false, }; for tok in s.split(',').map(str::trim).filter(|t| !t.is_empty()) { match tok { @@ -191,6 +196,7 @@ impl CompanionFeatures { "tasks" => f.tasks = true, "automations" => f.automations = true, "taskboard" => f.taskboard = true, + "mention_only" => f.mention_only = true, _ => {} } } @@ -407,6 +413,9 @@ pub async fn dispatch_line( }; remove_disabled_agents_from_delegate_enum(&mut tools, &ctx.disabled_agents); append_custom_agents_to_delegate_enum(&mut tools, &ctx.custom_agents); + if ctx.features.mention_only { + apply_mention_only_delegate_copy(&mut tools); + } LineAction::Respond(ok(id, json!({ "tools": tools }))) } "tools/call" => build_tools_call_spawn(ctx.clone(), inflight, id, req.params).await, @@ -473,6 +482,28 @@ fn append_custom_agents_to_delegate_enum(tools: &mut Value, custom_agents: &[Str } } +const MENTION_ONLY_DELEGATE_DESCRIPTION: &str = "Hand off a self-contained sub-task to a separate local AI agent that runs in its own session. ASYNCHRONOUS: returns a task_id right away and the sub-agent keeps working in the background — this call never blocks. So you can fan out several delegations at once and keep working, then collect the results with get_delegation_status by passing the task_ids array (one id to poll a single task, or many at once to poll the whole fan-out in one call) — or stop one early with cancel_delegation(task_id). The sub-agent CANNOT see this conversation, your open files, or earlier turns — it starts cold, so `task` must carry everything it needs. Best for independent, parallelizable work you can describe up front; not for steps that need your ongoing back-and-forth. RECOGNIZING AN EXPLICIT DELEGATION REQUEST: the user can name a sub-agent directly in their message — it appears as `@AgentName` or as a Markdown link `[@AgentName](codeg://agent/)`. Such a mention IS an explicit instruction to delegate the associated work to that agent, even when the user never names this tool. If the message names several agents, make one call per agent, each carrying that agent's slice of the work."; + +const MENTION_ONLY_AGENT_TYPE_DESCRIPTION: &str = "Which local agent runs the sub-task. Pick the one best suited to the work — or, when the user's message names an agent via `@AgentName` / `codeg://agent/`, use that exact `` slug (e.g. `codeg://agent/claude_code` means `claude_code`)."; + +/// Settings toggle off: restore the pre-self-initiate copy so models wait +/// for an `@` mention. The enum of launchable agents is unchanged. +fn apply_mention_only_delegate_copy(tools: &mut Value) { + let Some(arr) = tools.as_array_mut() else { + return; + }; + let Some(tool) = arr + .iter_mut() + .find(|t| t.get("name").and_then(|v| v.as_str()) == Some("delegate_to_agent")) + else { + return; + }; + tool["description"] = Value::String(MENTION_ONLY_DELEGATE_DESCRIPTION.to_string()); + if let Some(desc) = tool.pointer_mut("/inputSchema/properties/agent_type/description") { + *desc = Value::String(MENTION_ONLY_AGENT_TYPE_DESCRIPTION.to_string()); + } +} + /// Build the spawned-call descriptor for a `tools/call` (or, when the /// arguments are obviously bogus, a synchronous error response). Registers /// the inflight entry and returns a future the binary should drive. @@ -1539,6 +1570,7 @@ mod tests { tasks: false, automations: false, taskboard: false, + mention_only: false, }) } @@ -1616,6 +1648,45 @@ mod tests { assert!(status["inputSchema"]["properties"]["wait_ms"].is_object()); let required = status["inputSchema"]["required"].as_array().unwrap(); assert!(required.iter().any(|v| v == "task_ids")); + // Self-initiate is allowed: a mention is sufficient, not required. + // The old copy said a mention IS the trigger, so agents waited for `@`. + let desc = delegate["description"].as_str().unwrap(); + assert!( + desc.contains("YOU MAY CALL THIS TOOL WITHOUT A USER @ MENTION"), + "delegate_to_agent must invite self-initiated spawn" + ); + assert!( + desc.contains("Such a mention MUST be honored"), + "an @ mention must still force a delegation" + ); + assert!( + !desc.contains("Such a mention IS an explicit instruction"), + "do not tell the model that only a mention starts a sub-agent" + ); + let agent_desc = delegate["inputSchema"]["properties"]["agent_type"]["description"] + .as_str() + .unwrap(); + assert!(agent_desc.contains("even when the user did not @-mention")); + } + + #[tokio::test] + async fn mention_only_feature_restores_at_mention_copy() { + let mut features = CompanionFeatures::parse(Some("delegation,mention_only")); + assert!(features.delegation); + assert!(features.mention_only); + let ctx = ctx_with(features); + let line = r#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#; + let resp = unwrap_respond(dispatch_line(&ctx, Arc::new(InflightCalls::new()), line).await); + let delegate = resp.result.unwrap()["tools"] + .as_array() + .unwrap() + .iter() + .find(|t| t["name"] == "delegate_to_agent") + .unwrap() + .clone(); + let desc = delegate["description"].as_str().unwrap(); + assert!(desc.contains("Such a mention IS an explicit instruction")); + assert!(!desc.contains("YOU MAY CALL THIS TOOL WITHOUT A USER @ MENTION")); } #[tokio::test] @@ -2115,6 +2186,7 @@ mod tests { tasks: false, automations: false, taskboard: false, + mention_only: false, }; const BOTH: CompanionFeatures = CompanionFeatures { delegation: true, @@ -2124,6 +2196,7 @@ mod tests { tasks: false, automations: false, taskboard: false, + mention_only: false, }; const ASK_ONLY: CompanionFeatures = CompanionFeatures { delegation: false, @@ -2133,6 +2206,7 @@ mod tests { tasks: false, automations: false, taskboard: false, + mention_only: false, }; const SESSIONS_ONLY: CompanionFeatures = CompanionFeatures { delegation: false, @@ -2142,6 +2216,7 @@ mod tests { tasks: false, automations: false, taskboard: false, + mention_only: false, }; fn list_tool_names(action: LineAction) -> Vec { @@ -2432,6 +2507,7 @@ mod tests { tasks: false, automations: true, taskboard: false, + mention_only: false, }; const TASKBOARD_ONLY: CompanionFeatures = CompanionFeatures { delegation: false, @@ -2441,6 +2517,7 @@ mod tests { tasks: false, automations: false, taskboard: true, + mention_only: false, }; /// The two authoring groups gate independently: enabling one must not diff --git a/src-tauri/src/acp/delegation/tool_schema.json b/src-tauri/src/acp/delegation/tool_schema.json index 08002aaf4..d213526ce 100644 --- a/src-tauri/src/acp/delegation/tool_schema.json +++ b/src-tauri/src/acp/delegation/tool_schema.json @@ -1,7 +1,7 @@ [ { "name": "delegate_to_agent", - "description": "Hand off a self-contained sub-task to a separate local AI agent that runs in its own session. ASYNCHRONOUS: returns a task_id right away and the sub-agent keeps working in the background — this call never blocks. So you can fan out several delegations at once and keep working, then collect the results with get_delegation_status by passing the task_ids array (one id to poll a single task, or many at once to poll the whole fan-out in one call) — or stop one early with cancel_delegation(task_id). The sub-agent CANNOT see this conversation, your open files, or earlier turns — it starts cold, so `task` must carry everything it needs. Best for independent, parallelizable work you can describe up front; not for steps that need your ongoing back-and-forth. RECOGNIZING AN EXPLICIT DELEGATION REQUEST: the user can name a sub-agent directly in their message — it appears as `@AgentName` or as a Markdown link `[@AgentName](codeg://agent/)`. Such a mention IS an explicit instruction to delegate the associated work to that agent, even when the user never names this tool. If the message names several agents, make one call per agent, each carrying that agent's slice of the work.", + "description": "Hand off a self-contained sub-task to a separate local AI agent that runs in its own session. ASYNCHRONOUS: returns a task_id right away and the sub-agent keeps working in the background — this call never blocks. So you can fan out several delegations at once and keep working, then collect the results with get_delegation_status by passing the task_ids array (one id to poll a single task, or many at once to poll the whole fan-out in one call) — or stop one early with cancel_delegation(task_id). The sub-agent CANNOT see this conversation, your open files, or earlier turns — it starts cold, so `task` must carry everything it needs. Best for independent, parallelizable work you can describe up front; not for steps that need your ongoing back-and-forth. YOU MAY CALL THIS TOOL WITHOUT A USER @ MENTION. When the request has a self-contained sub-task that another listed agent is better suited for, or that can run in parallel, spawn it yourself. Pick agent_type only from this tool's enum (the built-ins listed here plus any custom: slugs shown). Do not invent slugs. Do not copy the whole conversation into the child — only a self-contained slice. Then keep working, or collect results with get_delegation_status. RECOGNIZING AN EXPLICIT DELEGATION REQUEST: the user can name a sub-agent directly in their message — it appears as `@AgentName` or as a Markdown link `[@AgentName](codeg://agent/)`. Such a mention MUST be honored: delegate the associated work to that agent even when the user never names this tool. If the message names several agents, make one call per agent, each carrying that agent's slice of the work.", "inputSchema": { "type": "object", "required": ["agent_type", "task"], @@ -23,7 +23,7 @@ "cursor", "deepseek" ], - "description": "Which local agent runs the sub-task. Pick the one best suited to the work — or, when the user's message names an agent via `@AgentName` / `codeg://agent/`, use that exact `` slug (e.g. `codeg://agent/claude_code` means `claude_code`)." + "description": "Which local agent runs the sub-task. You may pick the one best suited to the work even when the user did not @-mention anyone. When the user's message names an agent via `@AgentName` / `codeg://agent/`, use that exact `` slug (e.g. `codeg://agent/claude_code` means `claude_code`)." }, "task": { "type": "string", diff --git a/src-tauri/src/commands/delegation.rs b/src-tauri/src/commands/delegation.rs index 9c2e406e3..70d952f28 100644 --- a/src-tauri/src/commands/delegation.rs +++ b/src-tauri/src/commands/delegation.rs @@ -4,6 +4,7 @@ //! * `delegation.enabled` — feature kill switch (default false) //! * `delegation.depth_limit` — max chain depth a child is allowed to sit at //! * `delegation.agent_defaults` — per-agent spawn overrides (JSON blob) +//! * `delegation.allow_self_initiate` — agents may spawn without `@` //! * `delegation.completed_cache_max_mb` — per-parent byte budget (in MB) for //! the broker's in-memory cache of completed result text (`0` = unlimited) //! @@ -38,6 +39,13 @@ pub const KEY_DELEGATION_DEPTH: &str = "delegation.depth_limit"; pub const KEY_DELEGATION_AGENT_DEFAULTS: &str = "delegation.agent_defaults"; /// Per-parent completed-result cache budget, in MB. `0` = unlimited. pub const KEY_DELEGATION_COMPLETED_CACHE_MB: &str = "delegation.completed_cache_max_mb"; +/// When true (default), `delegate_to_agent` invites self-initiated spawn. +/// When false, the tool description says only an `@` mention starts a child. +pub const KEY_DELEGATION_ALLOW_SELF_INITIATE: &str = "delegation.allow_self_initiate"; + +fn default_allow_self_initiate() -> bool { + true +} pub const DEPTH_MIN: u32 = 1; pub const DEPTH_MAX: u32 = 8; @@ -71,6 +79,11 @@ pub struct DelegationSettings { /// unlimited), so an older client can't silently disable the valve. #[serde(default = "default_completed_cache_max_mb")] pub completed_cache_max_mb: u32, + /// When true, agents may call `delegate_to_agent` without an `@` + /// mention. An `@` mention is still always honored. Missing in a + /// payload → on, matching the post-#478 default. + #[serde(default = "default_allow_self_initiate")] + pub allow_self_initiate: bool, } impl Default for DelegationSettings { @@ -80,6 +93,7 @@ impl Default for DelegationSettings { depth_limit: 1, agent_defaults: BTreeMap::new(), completed_cache_max_mb: DEFAULT_COMPLETED_CACHE_MB, + allow_self_initiate: true, } } } @@ -97,6 +111,7 @@ impl DelegationSettings { // No upper clamp: the cache budget is a user memory choice, not a // safety rail. `0` stays `0` (unlimited). completed_cache_max_mb: self.completed_cache_max_mb, + allow_self_initiate: self.allow_self_initiate, } } @@ -135,6 +150,13 @@ pub async fn load_delegation_settings(conn: &DatabaseConnection) -> DelegationSe settings.completed_cache_max_mb = v; } } + if let Ok(Some(raw)) = + app_metadata_service::get_value(conn, KEY_DELEGATION_ALLOW_SELF_INITIATE).await + { + if let Ok(v) = raw.parse::() { + settings.allow_self_initiate = v; + } + } if let Ok(Some(raw)) = app_metadata_service::get_value(conn, KEY_DELEGATION_AGENT_DEFAULTS).await { @@ -154,6 +176,7 @@ pub async fn load_delegation_settings(conn: &DatabaseConnection) -> DelegationSe /// after any external write to `app_metadata`. pub async fn apply_persisted_config(conn: &DatabaseConnection, broker: &DelegationBroker) { let settings = load_delegation_settings(conn).await; + broker.set_allow_self_initiate(settings.allow_self_initiate); broker.set_config(settings.into_broker_config()).await; } @@ -188,12 +211,20 @@ pub async fn set_delegation_settings_core( let agent_defaults_json = serde_json::to_string(&clamped.agent_defaults).map_err(|e| { AppCommandError::configuration_invalid(format!("serialize agent_defaults: {e}")) })?; + app_metadata_service::upsert_value( + conn, + KEY_DELEGATION_ALLOW_SELF_INITIATE, + &clamped.allow_self_initiate.to_string(), + ) + .await + .map_err(AppCommandError::from)?; app_metadata_service::upsert_value(conn, KEY_DELEGATION_AGENT_DEFAULTS, &agent_defaults_json) .await .map_err(AppCommandError::from)?; broker .set_config(clamped.clone().into_broker_config()) .await; + broker.set_allow_self_initiate(clamped.allow_self_initiate); Ok(clamped) } diff --git a/src/components/settings/delegation-settings.test.tsx b/src/components/settings/delegation-settings.test.tsx index 9d08e4e1a..394a8e8b2 100644 --- a/src/components/settings/delegation-settings.test.tsx +++ b/src/components/settings/delegation-settings.test.tsx @@ -83,6 +83,7 @@ describe("DelegationSettingsSection", () => { await screen.findByLabelText("Maximum delegation depth") ).toBeInTheDocument() expect(screen.getByLabelText("Enable delegation")).toBeInTheDocument() + expect(screen.getByLabelText("Allow spawn without @")).toBeInTheDocument() expect( screen.getByLabelText("Completed-result cache (MB)") ).toBeInTheDocument() @@ -151,6 +152,7 @@ describe("DelegationSettingsSection", () => { await waitFor(() => { expect(mockSetDelegationSettings).toHaveBeenCalledWith({ enabled: true, + allow_self_initiate: true, depth_limit: 1, completed_cache_max_mb: 0, agent_defaults: {}, @@ -175,6 +177,7 @@ describe("DelegationSettingsSection", () => { await waitFor(() => { expect(mockSetDelegationSettings).toHaveBeenCalledWith({ enabled: true, + allow_self_initiate: true, depth_limit: 1, completed_cache_max_mb: 512, agent_defaults: {}, @@ -197,6 +200,7 @@ describe("DelegationSettingsSection", () => { await waitFor(() => { expect(mockSetDelegationSettings).toHaveBeenCalledWith({ enabled: true, + allow_self_initiate: true, depth_limit: 5, completed_cache_max_mb: 512, agent_defaults: {}, diff --git a/src/components/settings/delegation-settings.tsx b/src/components/settings/delegation-settings.tsx index 58e26dd4e..04ac40c00 100644 --- a/src/components/settings/delegation-settings.tsx +++ b/src/components/settings/delegation-settings.tsx @@ -66,6 +66,7 @@ export function DelegationSettingsSection() { const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [enabled, setEnabled] = useState(false) + const [allowSelfInitiate, setAllowSelfInitiate] = useState(true) const [depth, setDepth] = useState(1) const [cacheMb, setCacheMb] = useState(DEFAULT_CACHE_MB) const [agentDefaults, setAgentDefaults] = useState< @@ -83,6 +84,7 @@ export function DelegationSettingsSection() { .then((s) => { if (cancelled) return setEnabled(s.enabled) + setAllowSelfInitiate(s.allow_self_initiate !== false) setDepth(s.depth_limit) setCacheMb(s.completed_cache_max_mb) setAgentDefaults(s.agent_defaults ?? {}) @@ -136,6 +138,7 @@ export function DelegationSettingsSection() { const save = useCallback(async () => { const payload: DelegationSettings = { enabled, + allow_self_initiate: allowSelfInitiate, depth_limit: clamp(depth, DEPTH_MIN, DEPTH_MAX), completed_cache_max_mb: clampCacheMb(cacheMb), agent_defaults: agentDefaults, @@ -146,6 +149,7 @@ export function DelegationSettingsSection() { // Mirror any server-side clamps / filter passes back into the UI so the // inputs reflect what was actually persisted. setEnabled(applied.enabled) + setAllowSelfInitiate(applied.allow_self_initiate !== false) setDepth(applied.depth_limit) setCacheMb(applied.completed_cache_max_mb) setAgentDefaults(applied.agent_defaults ?? {}) @@ -157,7 +161,7 @@ export function DelegationSettingsSection() { } finally { setSaving(false) } - }, [enabled, depth, cacheMb, agentDefaults, t]) + }, [enabled, allowSelfInitiate, depth, cacheMb, agentDefaults, t]) return (

)} + + } + />