Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src-tauri/src/acp/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -3518,6 +3521,9 @@ fn companion_features_arg(flags: CompanionFeatureFlags) -> Option<String> {
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");
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -15951,6 +15958,7 @@ mod tests {
assert_eq!(
companion_features_arg(CompanionFeatureFlags {
delegation: true,
mention_only: false,
feedback: true,
ask: true,
sessions: true,
Expand All @@ -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`) ──
Expand Down
14 changes: 14 additions & 0 deletions src-tauri/src/acp/delegation/broker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -1255,6 +1256,10 @@ pub struct DelegationBroker {
tool_calls: Arc<ToolCallTracker>,
pre_canceled_handles: Arc<PreCanceledHandles>,
config: Arc<Mutex<DelegationConfig>>,
/// 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<AtomicBool>,
/// 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<Notify>,
Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -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`.
Expand Down
77 changes: 77 additions & 0 deletions src-tauri/src/acp/delegation/companion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -171,6 +174,7 @@ impl CompanionFeatures {
tasks: false,
automations: false,
taskboard: false,
mention_only: false,
};
};
let mut f = Self {
Expand All @@ -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 {
Expand All @@ -191,6 +196,7 @@ impl CompanionFeatures {
"tasks" => f.tasks = true,
"automations" => f.automations = true,
"taskboard" => f.taskboard = true,
"mention_only" => f.mention_only = true,
_ => {}
}
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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/<agent_type>)`. 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/<agent_type>`, use that exact `<agent_type>` 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.
Expand Down Expand Up @@ -1539,6 +1570,7 @@ mod tests {
tasks: false,
automations: false,
taskboard: false,
mention_only: false,
})
}

Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -2115,6 +2186,7 @@ mod tests {
tasks: false,
automations: false,
taskboard: false,
mention_only: false,
};
const BOTH: CompanionFeatures = CompanionFeatures {
delegation: true,
Expand All @@ -2124,6 +2196,7 @@ mod tests {
tasks: false,
automations: false,
taskboard: false,
mention_only: false,
};
const ASK_ONLY: CompanionFeatures = CompanionFeatures {
delegation: false,
Expand All @@ -2133,6 +2206,7 @@ mod tests {
tasks: false,
automations: false,
taskboard: false,
mention_only: false,
};
const SESSIONS_ONLY: CompanionFeatures = CompanionFeatures {
delegation: false,
Expand All @@ -2142,6 +2216,7 @@ mod tests {
tasks: false,
automations: false,
taskboard: false,
mention_only: false,
};

fn list_tool_names(action: LineAction) -> Vec<String> {
Expand Down Expand Up @@ -2432,6 +2507,7 @@ mod tests {
tasks: false,
automations: true,
taskboard: false,
mention_only: false,
};
const TASKBOARD_ONLY: CompanionFeatures = CompanionFeatures {
delegation: false,
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src-tauri/src/acp/delegation/tool_schema.json
Original file line number Diff line number Diff line change
@@ -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/<agent_type>)`. 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:<id> 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/<agent_type>)`. 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"],
Expand All @@ -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/<agent_type>`, use that exact `<agent_type>` 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/<agent_type>`, use that exact `<agent_type>` slug (e.g. `codeg://agent/claude_code` means `claude_code`)."
},
"task": {
"type": "string",
Expand Down
31 changes: 31 additions & 0 deletions src-tauri/src/commands/delegation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
//!
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
}
}
}
Expand All @@ -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,
}
}

Expand Down Expand Up @@ -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::<bool>() {
settings.allow_self_initiate = v;
}
}
if let Ok(Some(raw)) =
app_metadata_service::get_value(conn, KEY_DELEGATION_AGENT_DEFAULTS).await
{
Expand All @@ -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;
}

Expand Down Expand Up @@ -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)
}

Expand Down
Loading
Loading