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:
parent
0ff3882ca3
commit
aec1c02281
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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: 5–10 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()` |
|
||||
|
|
@ -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)]
|
||||
|
|
|
|||
Loading…
Reference in New Issue