Skip to content
Closed
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
14 changes: 14 additions & 0 deletions _docs/config/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,20 @@ Subagents do not inherit the parent model's reasoning effort. Set `reasoningEffo

Direct `@agent` invocation is available for visible `subagent` and `all` agents. For example, `@explore trace config loading` starts a child session through the Task flow.

## Disabling subagents

When subagents waste tokens (for example, a child agent returns an empty result), turn them off. The `task` tool is hidden from the model and any `task` call — including direct `@agent` mentions, which run through the same Task flow — is rejected with a permission error.

```sh
crabcode --disable-subagents
crabcode --disable-subagents -p "fix the failing test"
CRABCODE_DISABLE_SUBAGENTS=1 crabcode
```

The flag is global, so it also applies before subcommands (`crabcode --disable-subagents acp`, `crabcode --disable-subagents serve`). Remote `serve` hosts enforce whatever value was set in the host process environment.

`CRABCODE_DISABLE_SUBAGENTS` accepts `1`, `true`, `yes`, `y`, or `on` (case-insensitive); anything else, including an unset variable, keeps subagents enabled. Either source disables — there is no re-enable override once one of them says disabled.

## What belongs where

| Need | Put it in |
Expand Down
49 changes: 46 additions & 3 deletions src/config/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ pub struct ConfigRuntimeOptions {
pub print_mode: bool,
/// Skip permission prompts (print-mode `--dangerously-skip-permissions`).
pub dangerously_skip_permissions: bool,
/// Hide the `task` tool and reject subagent calls. Set from
/// `--disable-subagents` / `CRABCODE_DISABLE_SUBAGENTS`.
pub disable_subagents: bool,
}

/// Runtime pieces derived from merged config.
Expand All @@ -44,7 +47,10 @@ impl ConfigRuntime {

let mut permission_rules = merged.permission_rules.clone();
if options.print_mode {
permission_rules = deny_print_mode_interactive_tools(permission_rules);
permission_rules = deny_tools(permission_rules, &["question", "update_plan"]);
}
if options.disable_subagents || crate::tools::task::subagents_disabled() {
permission_rules = deny_tools(permission_rules, &["task"]);
}

let tool_permissions = ToolPermissions::new(cwd)
Expand Down Expand Up @@ -78,8 +84,8 @@ impl ConfigRuntime {
}
}

fn deny_print_mode_interactive_tools(mut rules: PermissionRules) -> PermissionRules {
for tool_id in ["question", "update_plan"] {
fn deny_tools(mut rules: PermissionRules, tool_ids: &[&str]) -> PermissionRules {
for tool_id in tool_ids {
rules.push(PermissionRule {
permission: tool_id.to_string(),
pattern: "*".to_string(),
Expand Down Expand Up @@ -167,6 +173,43 @@ mod tests {
assert!(discovery.provider_is_enabled("anthropic"));
}

#[test]
fn disable_subagents_denies_task_tool_only() {
// Ensure the env seam does not leak into this explicit-flag test.
let _lock = crate::tools::task::disable_subagents_env_lock();
let prev = std::env::var(crate::tools::task::DISABLE_SUBAGENTS_ENV).ok();
std::env::remove_var(crate::tools::task::DISABLE_SUBAGENTS_ENV);

let merged = MergedConfig::default();
let disabled_rt = ConfigRuntime::from_merged(
&merged,
"/tmp/workspace",
ConfigRuntimeOptions {
disable_subagents: true,
..Default::default()
},
);
let default_rt =
ConfigRuntime::from_merged(&merged, "/tmp/workspace", ConfigRuntimeOptions::default());

assert!(!disabled_rt
.tool_permissions
.is_tool_visible_for_agent("build", "task"));
// Other tools stay available.
assert!(disabled_rt
.tool_permissions
.is_tool_visible_for_agent("build", "read"));
// Default keeps subagents enabled.
assert!(default_rt
.tool_permissions
.is_tool_visible_for_agent("build", "task"));

match prev {
Some(value) => std::env::set_var(crate::tools::task::DISABLE_SUBAGENTS_ENV, value),
None => std::env::remove_var(crate::tools::task::DISABLE_SUBAGENTS_ENV),
}
}

#[test]
fn tui_and_print_share_same_tool_and_instruction_wiring() {
let mut merged = MergedConfig::default();
Expand Down
32 changes: 32 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,7 @@ async fn run_print_mode(
reasoning_override: Option<crate::model::reasoning::ReasoningEffort>,
no_session_persistence: bool,
dangerously_skip_permissions: bool,
disable_subagents: bool,
cli_agent: Option<&str>,
) -> Result<()> {
use crate::llm::client::stream_llm_with_cancellation;
Expand Down Expand Up @@ -448,6 +449,7 @@ async fn run_print_mode(
crate::config::ConfigRuntimeOptions {
print_mode: true,
dangerously_skip_permissions,
disable_subagents,
},
);
let discovery = runtime.discovery;
Expand Down Expand Up @@ -785,6 +787,12 @@ pub(crate) struct Args {
#[arg(long = "dangerously-skip-permissions")]
dangerously_skip_permissions: bool,

/// Disable subagents (hides the `task` tool and rejects subagent calls).
/// Same as setting `CRABCODE_DISABLE_SUBAGENTS=1`. Applies to TUI,
/// print mode, ACP, and serve.
#[arg(long = "disable-subagents", global = true)]
disable_subagents: bool,

#[arg(long = "emit-logs", hide = true)]
emit_logs: bool,

Expand Down Expand Up @@ -1042,6 +1050,14 @@ async fn main() -> Result<()> {
let _ = crate::logging::log(msg);
});

// Converge flag + env into the single `CRABCODE_DISABLE_SUBAGENTS`
// seam before any runtime (TUI, print, ACP, serve) is constructed, so
// permission checks, registry omission, prompt suppression, and
// `TaskTool::execute` all agree without threading a bool everywhere.
if args.disable_subagents {
std::env::set_var(crate::tools::task::DISABLE_SUBAGENTS_ENV, "1");
}

if args.test_notification {
send_test_notification()?;
return Ok(());
Expand Down Expand Up @@ -1222,6 +1238,7 @@ async fn main() -> Result<()> {
args.reasoning_effort,
args.no_session_persistence,
args.dangerously_skip_permissions,
crate::tools::task::resolve_subagents_disabled(args.disable_subagents),
args.agent.as_deref(),
)
.await;
Expand Down Expand Up @@ -1431,6 +1448,21 @@ mod tests {
assert_eq!(args.agent.as_deref(), Some("plan"));
}

#[test]
fn disable_subagents_defaults_off_and_parses_flag() {
let args = Args::try_parse_from(["crabcode", "-p", "hi"]).unwrap();
assert!(!args.disable_subagents);

let args = Args::try_parse_from(["crabcode", "-p", "hi", "--disable-subagents"]).unwrap();
assert!(args.disable_subagents);
}

#[test]
fn disable_subagents_flag_is_global_for_subcommands() {
let args = Args::try_parse_from(["crabcode", "--disable-subagents", "acp"]).unwrap();
assert!(args.disable_subagents);
}

#[test]
fn resolve_startup_agent_prefers_cli_over_default() {
let registry = crate::agent::definition::AgentRegistry::default();
Expand Down
9 changes: 8 additions & 1 deletion src/prompt/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -418,7 +418,14 @@ Your output will be displayed on a command line interface. Your responses should
}
}
}
let subagents = registry.visible_subagents();
// Add available subagents listing (suppressed when subagents are
// disabled so the model does not spend tokens attempting `task`
// calls that will be rejected).
let subagents = if crate::tools::task::subagents_disabled() {
Vec::new()
} else {
registry.visible_subagents()
};
if !subagents.is_empty() {
let subagents_xml = subagents
.iter()
Expand Down
58 changes: 51 additions & 7 deletions src/tools/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,13 +179,20 @@ pub async fn register_dynamic_tools(
))
.await;

registry
.register(Arc::new(
TaskTool::new(registry.clone())
.with_sender_opt(sender.clone())
.with_runtime_options(permissions, agent_registry, cancel_token),
))
.await;
// Omit (don't just deny) when disabled so `registry.get("task")` is
// None and the model never sees the tool. The flag path sets
// `CRABCODE_DISABLE_SUBAGENTS=1` early in `main`, so this single env
// check covers flag + env without threading a bool through every
// `register_dynamic_tools` call site (TUI, print, ACP, serve).
if !super::task::subagents_disabled() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Disable the VLM fallback hint alongside the task tool

When subagents are disabled and an image is sent to a text-only model with a configured vlm-agent, llm/client.rs:726-728 still sets show_vlm_agent_hint to true. The converted user message therefore commands the model to call task, while this branch omits that tool, and the normal unsupported-image warning is also suppressed. Include the disabled state when deciding whether to advertise the VLM fallback so these requests receive the warning instead of an impossible tool instruction.

Useful? React with 👍 / 👎.

registry
.register(Arc::new(
TaskTool::new(registry.clone())
.with_sender_opt(sender.clone())
.with_runtime_options(permissions, agent_registry, cancel_token),
))
.await;
}

// Keep terminal_session as a thin interactive alias for back-compat.
registry
Expand Down Expand Up @@ -277,6 +284,9 @@ mod tests {

#[tokio::test]
async fn dynamic_registry_contains_runtime_tools() {
let _lock = crate::tools::task::disable_subagents_env_lock();
let prev = std::env::var(crate::tools::task::DISABLE_SUBAGENTS_ENV).ok();
std::env::remove_var(crate::tools::task::DISABLE_SUBAGENTS_ENV);
let registry = initialize_tool_registry_with_dynamic(
None,
ToolPermissions::new("."),
Expand All @@ -292,10 +302,40 @@ mod tests {
assert!(registry.get("bash_output").await.is_some());
assert!(registry.get("bash_kill").await.is_some());
assert!(registry.get("bash_restart").await.is_some());
match prev {
Some(value) => std::env::set_var(crate::tools::task::DISABLE_SUBAGENTS_ENV, value),
None => std::env::remove_var(crate::tools::task::DISABLE_SUBAGENTS_ENV),
}
}

#[tokio::test]
async fn disabled_env_omits_task_tool_from_dynamic_registry() {
let _lock = crate::tools::task::disable_subagents_env_lock();
let prev = std::env::var(crate::tools::task::DISABLE_SUBAGENTS_ENV).ok();
std::env::set_var(crate::tools::task::DISABLE_SUBAGENTS_ENV, "1");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid process-wide env mutation in parallel tests

Under the default parallel Rust test runner, this process-wide environment mutation is serialized only against tests that explicitly acquire disable_subagents_env_lock; existing tests such as tools::task::tests::task_requires_parent_session_scoped_llm_config do not acquire it, yet TaskTool::execute now reads this variable. If those tests overlap, the latter returns the new disabled permission error instead of its expected missing-session error, making the suite nondeterministically fail. Use injected state for this test or ensure every reader participates in the same synchronization.

Useful? React with 👍 / 👎.

let registry = initialize_tool_registry_with_dynamic(
None,
ToolPermissions::new("."),
crate::agent::definition::AgentRegistry::default(),
CancellationToken::new(),
Arc::new(ProcessRegistry::new()),
)
.await;

assert!(registry.get("task").await.is_none());
// Interactive tools stay registered.
assert!(registry.get("question").await.is_some());
match prev {
Some(value) => std::env::set_var(crate::tools::task::DISABLE_SUBAGENTS_ENV, value),
None => std::env::remove_var(crate::tools::task::DISABLE_SUBAGENTS_ENV),
}
}

#[tokio::test]
async fn scoped_plan_registry_hides_mutating_tools() {
let _lock = crate::tools::task::disable_subagents_env_lock();
let prev = std::env::var(crate::tools::task::DISABLE_SUBAGENTS_ENV).ok();
std::env::remove_var(crate::tools::task::DISABLE_SUBAGENTS_ENV);
let permissions = ToolPermissions::new(".");
let registry = initialize_tool_registry_with_dynamic(
None,
Expand All @@ -317,6 +357,10 @@ mod tests {
assert!(scoped.get("apply_patch").await.is_none());
assert!(scoped.get("write").await.is_none());
assert!(scoped.get("edit").await.is_none());
match prev {
Some(value) => std::env::set_var(crate::tools::task::DISABLE_SUBAGENTS_ENV, value),
None => std::env::remove_var(crate::tools::task::DISABLE_SUBAGENTS_ENV),
}
}

#[tokio::test]
Expand Down
8 changes: 8 additions & 0 deletions src/tools/permission.rs
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,14 @@ impl ToolPermissions {
}

pub fn is_tool_allowed_for_agent(&self, agent_mode: &str, tool_id: &str) -> bool {
// Central kill-switch: `CRABCODE_DISABLE_SUBAGENTS=1` hides the
// `task` tool in every runtime (TUI, print, ACP, serve) without
// threading a flag through each entrypoint. Explicit
// `--disable-subagents` sets this env early in `main`, so both
// sources converge here plus the `ConfigRuntime` deny rule.
if tool_id == "task" && super::task::subagents_disabled() {
return false;
}
self.agent_policies.is_allowed(agent_mode, tool_id)
&& self
.global_tool_config
Expand Down
Loading
Loading