feat: SubAgent tool for fast sub-agent delegation

Add a SubAgent tool that lets the main model delegate multi-step read
and search tasks to a fast, inexpensive sub-agent. The sub-agent runs
autonomously with its own ConversationRuntime, making multiple tool
calls without round-tripping through the main model.

- SubAgent tool with prompt, task_type (Explore/Plan/Verify), and
  optional model override
- Explore: read_file, glob_search, grep_search, WebFetch, WebSearch
- Plan: Explore + TodoWrite
- Verify: Plan + bash
- subagentModel config field in settings.json (falls back to main model)
- Uses ProviderRuntimeClient + SubagentToolExecutor (same as Agent tool)

This dramatically reduces token usage for information-gathering tasks.
Example: "find all Rust files that import X and summarize their usage"
→ one SubAgent call instead of 10 sequential main-model round trips.
This commit is contained in:
TheArchitectit 2026-04-28 12:03:18 -05:00
parent 0ff3882ca3
commit aec1c02281
4 changed files with 282 additions and 0 deletions

View File

@ -210,6 +210,7 @@ pub struct RuntimeFeatureConfig {
provider_fallbacks: ProviderFallbackConfig,
trusted_roots: Vec<String>,
<<<<<<< HEAD
<<<<<<< HEAD
<<<<<<< HEAD
api_timeout: ApiTimeoutConfig,
rules_import: RulesImportConfig,
@ -353,6 +354,9 @@ impl RulesImportConfig {
}
}
>>>>>>> 22f948b7 (feat: project rules with .claw/rules/ and multi-framework auto-import)
=======
subagent_model: Option<String>,
>>>>>>> 7e7baeaa (feat: SubAgent tool for fast sub-agent delegation)
}
/// Ordered chain of fallback model identifiers used when the primary
@ -798,6 +802,7 @@ impl ConfigLoader {
sandbox: parse_optional_sandbox_config(&merged_value)?,
provider_fallbacks: parse_optional_provider_fallbacks(&merged_value)?,
trusted_roots: parse_optional_trusted_roots(&merged_value)?,
<<<<<<< HEAD
<<<<<<< HEAD
provider: parse_optional_provider_config(&merged_value)?,
lsp: parse_optional_lsp_config(&merged_value)?,
@ -823,6 +828,9 @@ impl ConfigLoader {
=======
rules_import: parse_optional_rules_import(&merged_value)?,
>>>>>>> 22f948b7 (feat: project rules with .claw/rules/ and multi-framework auto-import)
=======
subagent_model: parse_optional_subagent_model(&merged_value),
>>>>>>> 7e7baeaa (feat: SubAgent tool for fast sub-agent delegation)
};
ConfigInspection {
@ -1081,6 +1089,7 @@ impl RuntimeConfig {
&self.feature_config.trusted_roots
}
<<<<<<< HEAD
<<<<<<< HEAD
#[must_use]
<<<<<<< HEAD
@ -1127,6 +1136,12 @@ impl RuntimeConfig {
&self.feature_config.rules_import
}
>>>>>>> 22f948b7 (feat: project rules with .claw/rules/ and multi-framework auto-import)
=======
#[must_use]
pub fn subagent_model(&self) -> Option<&str> {
self.feature_config.subagent_model.as_deref()
}
>>>>>>> 7e7baeaa (feat: SubAgent tool for fast sub-agent delegation)
}
impl RuntimeFeatureConfig {
@ -1939,6 +1954,19 @@ fn parse_optional_model(root: &JsonValue) -> Option<String> {
.map(ToOwned::to_owned)
}
fn parse_optional_subagent_model(value: &JsonValue) -> Option<String> {
value
.as_object()
.and_then(|object| {
object
.get("subagentModel")
.or_else(|| object.get("subagent_model"))
})
.and_then(|v| v.as_str())
.filter(|s| !s.trim().is_empty())
.map(|s| s.trim().to_string())
}
fn parse_optional_aliases(root: &JsonValue) -> Result<BTreeMap<String, String>, ConfigError> {
let Some(object) = root.as_object() else {
return Ok(BTreeMap::new());

View File

@ -209,6 +209,7 @@ const TOP_LEVEL_FIELDS: &[FieldSpec] = &[
expected: FieldType::StringArray,
},
FieldSpec {
<<<<<<< HEAD
<<<<<<< HEAD
name: "provider",
expected: FieldType::Object,
@ -242,6 +243,10 @@ const TOP_LEVEL_FIELDS: &[FieldSpec] = &[
name: "rulesImport",
expected: FieldType::String,
>>>>>>> 22f948b7 (feat: project rules with .claw/rules/ and multi-framework auto-import)
=======
name: "subagentModel",
expected: FieldType::String,
>>>>>>> 7e7baeaa (feat: SubAgent tool for fast sub-agent delegation)
},
];

View File

@ -0,0 +1,118 @@
# Multi-Tool Execution & Sub-Agent Delegation
Two complementary features that dramatically reduce latency and token usage when the model needs to perform multiple operations or gather context.
## Feature 1: Parallel Tool Execution
When the model returns multiple tool_use blocks in a single response, read-only tools now execute concurrently instead of sequentially.
### How it works
The `run_turn` loop is refactored into 3 phases:
1. **Pre-hooks + permission checks** (sequential — hooks may mutate state)
2. **Tool execution** (batch — parallel for read-only tools via `std::thread::scope`)
3. **Post-hooks + session updates** (sequential — preserves original ordering)
### Parallel-safe tools
These tools are safe to run concurrently because they only read state and dispatch through the stateless tool registry:
- `read_file`, `glob_search`, `grep_search`
- `WebFetch`, `WebSearch`
- `ToolSearch`, `Skill`
- `LSP`
- `GitStatus`, `GitDiff`, `GitLog`, `GitShow`, `GitBlame`
### Sequential-only tools
Tools that require `&mut self` or have side effects continue to run one at a time:
- `bash`, `write_file`, `edit_file` (side effects)
- `MCP`, `McpAuth`, `RemoteTrigger` (network state)
- `Agent`, `TaskCreate`, `WorkerCreate` (stateful)
- `NotebookEdit`, `REPL`, `PowerShell` (side effects)
### Safety guarantees
- Pre/post hooks always run sequentially
- Permission checks complete before any tool executes
- Tool results are pushed to the session in the original model order
- Falls back to sequential for single-tool batches
- Thread scopes ensure all parallel work completes before `execute_batch` returns
### Impact
For a response with 5 `read_file` calls: **~5x faster** execution. The main model still sees all results in order.
---
## Feature 2: SubAgent Delegation
A `SubAgent` tool that lets the main model delegate multi-step tasks to a fast sub-agent. The sub-agent runs autonomously with its own `ConversationRuntime`, making multiple tool calls without round-tripping through the main model.
### Tool parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `prompt` | string | yes | Task description for the sub-agent |
| `task_type` | string | no | `Explore` (default), `Plan`, or `Verify` |
| `model` | string | no | Override the sub-agent model |
### Task types
| Type | Available tools | Use for |
|------|----------------|---------|
| `Explore` | read_file, glob_search, grep_search, WebFetch, WebSearch, ToolSearch, StructuredOutput | Searching code, reading files, gathering context |
| `Plan` | Explore + TodoWrite | Planning approaches with structured todo output |
| `Verify` | Plan + bash | Running tests, checking builds, verifying changes |
### Configuration
Set the sub-agent model in `~/.claw/settings.json`:
```json
{
"model": "openai/glm-5.1-fast",
"subagentModel": "openai/qwen3.6-35b-fast"
}
```
If `subagentModel` is not set, the sub-agent uses the same model as the main session (or the `model` override parameter on the tool call).
### Example
Main model prompt: "Find all Rust files that import `ConversationRuntime` and list their paths"
Without SubAgent: 510 sequential tool calls (grep → read each file → summarize)
With SubAgent: 1 SubAgent call → sub-agent does all the work autonomously → returns summary
**Result**: ~10x fewer tokens consumed by the main model, faster overall completion.
### Architecture
The sub-agent reuses the same building blocks as the existing `Agent` tool:
- `ProviderRuntimeClient` — API client with fallback chain
- `SubagentToolExecutor` — filtered tool access with permission enforcement
- `ConversationRuntime` — full conversation loop with hooks and compaction
- `agent_permission_policy()` — auto-approve read-only, deny write tools
Key differences from the `Agent` tool:
- **Synchronous** — blocks until complete, returns result directly
- **Lighter** — fewer default tools, focused on the task type
- **Configurable model** — uses `subagentModel` or the tool's `model` param
- **Structured output** — returns `result`, `tool_calls`, and `iterations`
---
## Changed files
| File | Changes |
|------|---------|
| `rust/crates/runtime/src/conversation.rs` | `ToolCall`, `ToolResult` types; `execute_batch` on `ToolExecutor`; 3-phase `run_turn` |
| `rust/crates/runtime/src/lib.rs` | Exports for `ToolCall`, `ToolResult` |
| `rust/crates/runtime/src/config.rs` | `subagent_model` field, `parse_optional_subagent_model()`, accessor |
| `rust/crates/runtime/src/config_validate.rs` | `subagentModel` field spec |
| `rust/crates/rusty-claude-cli/src/main.rs` | `CliToolExecutor::execute_batch` with parallel-safe classification |
| `rust/crates/tools/src/lib.rs` | `SubAgent` tool spec, `SubAgentInput`, `run_sub_agent()`, `load_subagent_model_from_config()`, `build_sub_agent_system_prompt()` |

View File

@ -1276,6 +1276,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
required_permission: PermissionMode::DangerFullAccess,
},
ToolSpec {
<<<<<<< HEAD
name: "GitStatus",
description: "Show the working tree status (branch, staged, unstaged, untracked). Equivalent to 'git status --short --branch'. Use this instead of running git status via bash to get structured, parseable output.",
input_schema: json!({
@ -1349,6 +1350,25 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
"additionalProperties": false
}),
required_permission: PermissionMode::ReadOnly,
=======
name: "SubAgent",
description: "Launch a lightweight sub-agent that autonomously performs multi-step work using a fast model. Use for tasks like searching code, reading multiple files, or gathering context that would require many sequential tool calls. Returns a summary of the sub-agent's findings.",
input_schema: json!({
"type": "object",
"properties": {
"prompt": { "type": "string" },
"task_type": {
"type": "string",
"enum": ["Explore", "Plan", "Verify"],
"description": "Explore=search+read only, Plan=explore+todo, Verify=explore+bash+todo"
},
"model": { "type": "string" }
},
"required": ["prompt"],
"additionalProperties": false
}),
required_permission: PermissionMode::DangerFullAccess,
>>>>>>> 7e7baeaa (feat: SubAgent tool for fast sub-agent delegation)
},
]
}
@ -1498,11 +1518,15 @@ fn execute_tool_with_enforcer(
"TestingPermission" => {
from_value::<TestingPermissionInput>(input).and_then(run_testing_permission)
}
<<<<<<< HEAD
"GitStatus" => from_value::<GitStatusInput>(input).and_then(run_git_status),
"GitDiff" => from_value::<GitDiffInput>(input).and_then(run_git_diff),
"GitLog" => from_value::<GitLogInput>(input).and_then(run_git_log),
"GitShow" => from_value::<GitShowInput>(input).and_then(run_git_show),
"GitBlame" => from_value::<GitBlameInput>(input).and_then(run_git_blame),
=======
"SubAgent" => from_value::<SubAgentInput>(input).and_then(run_sub_agent),
>>>>>>> 7e7baeaa (feat: SubAgent tool for fast sub-agent delegation)
_ => Err(format!("unsupported tool: {name}")),
}
}
@ -2051,6 +2075,7 @@ fn run_testing_permission(input: TestingPermissionInput) -> Result<String, Strin
}
#[allow(clippy::needless_pass_by_value)]
<<<<<<< HEAD
/// Execute `git status --short --branch` and return structured JSON output.
/// Falls back to full `git status` if `short` is explicitly set to false.
fn run_git_status(input: GitStatusInput) -> Result<String, String> {
@ -2197,6 +2222,102 @@ fn run_git_blame(input: GitBlameInput) -> Result<String, String> {
})),
None => Err(format!("git blame {} failed. Ensure the file exists and the directory is inside a git repository.", input.path)),
}
=======
fn run_sub_agent(input: SubAgentInput) -> Result<String, String> {
let task_type = input.task_type.as_deref().unwrap_or("Explore");
let allowed_tools: BTreeSet<String> = match task_type {
"Plan" => BTreeSet::from([
"read_file".to_string(),
"glob_search".to_string(),
"grep_search".to_string(),
"ToolSearch".to_string(),
"TodoWrite".to_string(),
"StructuredOutput".to_string(),
]),
"Verify" => BTreeSet::from([
"bash".to_string(),
"read_file".to_string(),
"glob_search".to_string(),
"grep_search".to_string(),
"ToolSearch".to_string(),
"TodoWrite".to_string(),
"StructuredOutput".to_string(),
]),
_ => BTreeSet::from([
"read_file".to_string(),
"glob_search".to_string(),
"grep_search".to_string(),
"WebFetch".to_string(),
"WebSearch".to_string(),
"ToolSearch".to_string(),
"StructuredOutput".to_string(),
]),
};
let model = input
.model
.or_else(|| load_subagent_model_from_config())
.unwrap_or_else(|| DEFAULT_AGENT_MODEL.to_string());
let system_prompt = build_sub_agent_system_prompt(task_type)
.map_err(|e| format!("failed to build sub-agent system prompt: {e}"))?;
let api_client = ProviderRuntimeClient::new(model.clone(), allowed_tools.clone())
.map_err(|e| format!("failed to create sub-agent API client: {e}"))?;
let permission_policy = agent_permission_policy();
let tool_executor = SubagentToolExecutor::new(allowed_tools)
.with_enforcer(PermissionEnforcer::new(permission_policy.clone()));
let mut runtime = ConversationRuntime::new(
Session::new(),
api_client,
tool_executor,
permission_policy,
system_prompt,
)
.with_max_iterations(DEFAULT_AGENT_MAX_ITERATIONS);
let summary = runtime
.run_turn(input.prompt, None)
.map_err(|e| format!("sub-agent failed: {e}"))?;
let result_text = final_assistant_text(&summary);
let tool_count: usize = summary
.assistant_messages
.iter()
.map(|m| m.blocks.iter().filter(|b| matches!(b, ContentBlock::ToolUse { .. })).count())
.sum();
to_pretty_json(json!({
"result": result_text,
"tool_calls": tool_count,
"iterations": summary.iterations,
}))
}
fn load_subagent_model_from_config() -> Option<String> {
std::env::current_dir().ok().and_then(|cwd| {
ConfigLoader::default_for(cwd)
.load()
.ok()
.and_then(|config| config.subagent_model().map(|m| m.to_string()))
})
}
fn build_sub_agent_system_prompt(task_type: &str) -> Result<Vec<String>, String> {
let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
let mut prompt = load_system_prompt(
cwd,
DEFAULT_AGENT_SYSTEM_DATE.to_string(),
std::env::consts::OS,
"unknown",
)
.map_err(|error| error.to_string())?;
prompt.push(format!(
"You are a fast sub-agent performing a {task_type} task. Work autonomously, use available tools efficiently, do not ask questions. Return a concise summary of your findings."
));
Ok(prompt)
>>>>>>> 7e7baeaa (feat: SubAgent tool for fast sub-agent delegation)
}
fn from_value<T: for<'de> Deserialize<'de>>(input: &Value) -> Result<T, String> {
@ -3181,6 +3302,7 @@ struct TestingPermissionInput {
action: String,
}
<<<<<<< HEAD
/// Input for the GitStatus tool: shows working tree status.
/// Defaults to --short --branch mode for concise, parseable output.
#[derive(Debug, Deserialize)]
@ -3261,6 +3383,15 @@ struct GitBlameInput {
#[serde(default)]
/// End of line range (1-based). Only used if start_line is also set.
end_line: Option<usize>,
=======
#[derive(Debug, Deserialize)]
struct SubAgentInput {
prompt: String,
#[serde(default)]
task_type: Option<String>,
#[serde(default)]
model: Option<String>,
>>>>>>> 7e7baeaa (feat: SubAgent tool for fast sub-agent delegation)
}
#[derive(Debug, Serialize)]