Skip to main content

defect_cli/
tools.rs

1//! Assembles the `process_tools` registry.
2//!
3//! The tools grouped here (bash / fs / fetch / search / skill / spawn_agent, etc.) are
4//! mounted once on a [`StaticToolRegistry`] as the `process_tools` of an `AgentCore`
5//! instance, shared across all sessions of that core — **not a process-global singleton**
6//! (when using defect as a library, a single process may have multiple `AgentCore`
7//! instances, each with its own copy). MCP tools go through the session-level
8//! [`McpToolFactory`](defect_mcp::McpToolFactory) assembled in the `mcp_servers` module.
9
10use std::collections::BTreeMap;
11use std::sync::Arc;
12
13use defect_agent::hooks::HookEngine;
14use defect_agent::hooks::builtin::BuiltinRegistry;
15use defect_agent::llm::ProviderRegistry;
16use defect_agent::policy::SandboxPolicy;
17use defect_agent::session::{CompositeRegistry, StaticToolRegistry, ToolRegistry};
18use defect_agent::tool::{
19    CancelBackgroundTaskTool, InspectBackgroundTaskTool, SkillEntry, SkillTool, SpawnAgentTool,
20    SubagentProfile,
21};
22use defect_config::{LoadedConfig, ProfileSpec, SkillSpec};
23use defect_tools::{BashTool, EditFileTool, FetchTool, ReadFileTool, SearchTool, WriteFileTool};
24
25use crate::hooks::{HookEngineBuildError, HookEngineCtx, build_engine_arc};
26
27/// Assembles the `process_tools` tool set from the `[tools]` section (shared across
28/// sessions for a given `AgentCore` instance).
29///
30/// `fetch` / `search` are individually controlled via the `enabled` field; the local
31/// `search` tool is completely independent from the hosted `web_search` capability — both
32/// can be enabled simultaneously.
33pub fn build_process_tools(config: &LoadedConfig) -> Arc<dyn ToolRegistry> {
34    let mut builder = StaticToolRegistry::builder()
35        .insert(Arc::new(BashTool::from_config(
36            &config.effective.tools.bash,
37        )))
38        .insert(Arc::new(ReadFileTool::from_config(
39            &config.effective.tools.fs,
40        )))
41        .insert(Arc::new(WriteFileTool::new()))
42        .insert(Arc::new(EditFileTool::new()));
43    if config.effective.tools.fetch.enabled {
44        builder = builder.insert(Arc::new(FetchTool::from_config(
45            &config.effective.tools.fetch,
46        )));
47    }
48    if config.effective.tools.search.enabled {
49        builder = builder.insert(Arc::new(SearchTool::from_config(
50            &config.effective.tools.search,
51        )));
52    }
53    Arc::new(builder.build())
54}
55
56/// Filters the base tool set to a subset according to an allowlist, for use with the
57/// top-level `--profile` (which runs the entire session as a single profile). Unknown
58/// tool names are a hard error (fail loud). `spawn_agent` is excluded even if present in
59/// the allowlist — a top-level profile is a leaf agent and does not spawn child agents.
60///
61/// # Errors
62/// Returns `Err(name)` if the profile's `allow` contains a name not present in the base
63/// tool set.
64pub fn filter_tools_by_allowlist(
65    base: &Arc<dyn ToolRegistry>,
66    allow: &[String],
67) -> Result<Arc<dyn ToolRegistry>, String> {
68    let mut builder = StaticToolRegistry::builder();
69    for name in allow {
70        if name == "spawn_agent" {
71            continue;
72        }
73        match base.get(name) {
74            Some(tool) => builder = builder.insert(tool),
75            None => return Err(name.clone()),
76        }
77    }
78    Ok(Arc::new(builder.build()))
79}
80
81/// Projects [`ProfileSpec`] from `defect-config` into the agent-side [`SubagentProfile`],
82/// and compiles each profile's declared `[hooks]` into a hook engine injection.
83///
84/// The split exists because `defect-config` depends on `defect-agent` (a reverse
85/// dependency would create a cycle); the CLI performs this projection at the assembly
86/// boundary. The hook engine is assembled here because it needs the builtin registry and
87/// provider registry (same origin as the main session's hook assembly, see
88/// [`crate::hooks`]).
89///
90/// An empty `[hooks]` in a profile ⇒ `hooks: None` (the sub-agent has no hooks, matching
91/// pre-change behavior).
92///
93/// # Errors
94/// Hard-fails if hook engine assembly fails for any profile (unknown builtin, prompt hook
95/// reference to an unregistered model, etc.). The error includes the profile name for
96/// identification.
97fn project_profiles(
98    specs: &BTreeMap<String, ProfileSpec>,
99    builtins: &BuiltinRegistry,
100    hook_rt: &HookEngineCtx<'_>,
101) -> Result<BTreeMap<String, SubagentProfile>, ProfileHookBuildError> {
102    specs
103        .iter()
104        .map(|(name, spec)| {
105            let hooks = if spec.hooks.is_empty() {
106                None
107            } else {
108                let engine = build_engine_arc(&spec.hooks, builtins, hook_rt).map_err(|err| {
109                    ProfileHookBuildError {
110                        profile: name.clone(),
111                        source: err,
112                    }
113                })?;
114                Some(engine as Arc<dyn HookEngine>)
115            };
116            Ok((
117                name.clone(),
118                SubagentProfile {
119                    description: spec.description.clone(),
120                    model: spec.model.clone(),
121                    system_prompt: spec.system_prompt_text.clone(),
122                    tool_allow: spec.tool_allow.clone(),
123                    sampling: spec.sampling.clone(),
124                    hooks,
125                },
126            ))
127        })
128        .collect()
129}
130
131/// Hook engine build failed for a subagent profile; include the profile name for
132/// identification.
133#[derive(Debug, thiserror::Error)]
134#[error("subagent profile `{profile}` hook engine build failed: {source}")]
135pub struct ProfileHookBuildError {
136    pub profile: String,
137    #[source]
138    pub source: HookEngineBuildError,
139}
140
141/// Project [`SkillSpec`] from `defect-config` into the agent-side [`SkillEntry`],
142/// mirroring the cross-crate assembly-boundary projection pattern used in
143/// `project_profiles`.
144pub fn project_skills(specs: &BTreeMap<String, SkillSpec>) -> BTreeMap<String, SkillEntry> {
145    specs
146        .iter()
147        .map(|(name, spec)| {
148            (
149                name.clone(),
150                SkillEntry {
151                    description: spec.description.clone(),
152                    body: spec.body.clone(),
153                    dir: spec.dir.clone(),
154                    always: spec.always,
155                    triggers: spec.triggers.clone(),
156                },
157            )
158        })
159        .collect()
160}
161
162/// Assembles the process tool set, overlaying `spawn_agent` and `skill` tools when
163/// profiles or skills are present.
164///
165/// Composition: first build the base tool set (bash/fs/fetch/search), then place
166/// `spawn_agent` (when any profile is found) and `skill` (when any skill is found) into
167/// an overlay registry, and combine them with [`CompositeRegistry`] on top of the base.
168///
169/// - `spawn_agent`'s "child tool source" is the **base tool set** (without these overlay
170///   tools), so child agents structurally cannot access `spawn_agent`—preventing
171///   recursion; they also cannot access `skill` (skill is a top-level agent capability;
172///   child agents use their own profile prompt); similarly they cannot access
173///   `inspect_background_task` / `cancel_background_task` (the background task table
174///   belongs to the top-level session, and child agents' nested turns have
175///   `ToolContext::background` as `None`).
176/// - When both profiles and skills are empty, no overlay is applied and the pure base is
177///   returned.
178///
179/// `base_prompt` is inherited by child agents (the "you are an agent that uses tools"
180/// base prompt); the profile's role prompt is appended separately.
181///
182/// `builtins` / `hook_rt` are used to compile each profile's `[hooks]` into a hook engine
183/// (see `project_profiles`)—a child agent's hooks are part of its identity and are not
184/// inherited from the parent.
185///
186/// # Errors
187/// If any profile's hook engine fails to build, it is a hard failure
188/// ([`ProfileHookBuildError`]).
189// This is a boundary assembly function: its parameters are the individual components of
190// `AgentCore`; extracting them into a struct would fragment the call site (in `cli.rs`,
191// they are passed one by one), so two extra hook-assembly dependencies are kept inline.
192#[allow(clippy::too_many_arguments)]
193pub fn build_process_tools_with_subagents(
194    config: &LoadedConfig,
195    profiles: &BTreeMap<String, ProfileSpec>,
196    skills: &BTreeMap<String, SkillEntry>,
197    registry: &Arc<ProviderRegistry>,
198    policy: &Arc<dyn SandboxPolicy>,
199    base_prompt: Option<String>,
200    builtins: &BuiltinRegistry,
201    hook_rt: &HookEngineCtx<'_>,
202) -> Result<Arc<dyn ToolRegistry>, ProfileHookBuildError> {
203    let base = build_process_tools(config);
204    let projected = project_profiles(profiles, builtins, hook_rt)?;
205    let has_profiles = SpawnAgentTool::has_profiles(&projected);
206    let has_skills = SkillTool::has_skills(skills);
207    if !has_profiles && !has_skills {
208        return Ok(base);
209    }
210
211    let mut overlay = StaticToolRegistry::builder();
212    if has_profiles {
213        let spawn = SpawnAgentTool::new(
214            Arc::new(projected),
215            registry.clone(),
216            policy.clone(),
217            base.clone(),
218            base_prompt,
219        );
220        overlay = overlay.insert(Arc::new(spawn));
221        // Background task control surface: query progress / early cancellation. Same tier
222        // as `spawn_agent` — only meaningful when the agent can spawn background
223        // subagents (`has_profiles`), and likewise only inserted into the overlay, not
224        // into the subagent's tool subset source, so subagents structurally cannot reach
225        // it (same reasoning as disabling recursion).
226        overlay = overlay.insert(Arc::new(InspectBackgroundTaskTool::new()));
227        overlay = overlay.insert(Arc::new(CancelBackgroundTaskTool::new()));
228    }
229    if has_skills {
230        let skill = SkillTool::new(Arc::new(skills.clone()));
231        overlay = overlay.insert(Arc::new(skill));
232    }
233    let overlay_reg: Arc<dyn ToolRegistry> = Arc::new(overlay.build());
234    Ok(Arc::new(CompositeRegistry::new(overlay_reg, base)))
235}