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// NOTE: the top-level `--profile` tool allowlist is no longer filtered here at assembly
57// time. It is enforced per-session by `DefaultAgentCore::apply_tool_allow` (which reuses
58// `defect_agent::session::filter_registry_by_allowlist`) AFTER MCP tools join the pool, so
59// profiles may allow `mcp__*` tools. See assembly.rs `build_default_process_tools`.
60
61/// Projects [`ProfileSpec`] from `defect-config` into the agent-side [`SubagentProfile`],
62/// and compiles each profile's declared `[hooks]` into a hook engine injection.
63///
64/// The split exists because `defect-config` depends on `defect-agent` (a reverse
65/// dependency would create a cycle); the CLI performs this projection at the assembly
66/// boundary. The hook engine is assembled here because it needs the builtin registry and
67/// provider registry (same origin as the main session's hook assembly, see
68/// [`crate::hooks`]).
69///
70/// An empty `[hooks]` in a profile ⇒ `hooks: None` (the sub-agent has no hooks, matching
71/// pre-change behavior).
72///
73/// # Errors
74/// Hard-fails if hook engine assembly fails for any profile (unknown builtin, prompt hook
75/// reference to an unregistered model, etc.). The error includes the profile name for
76/// identification.
77fn project_profiles(
78    specs: &BTreeMap<String, ProfileSpec>,
79    builtins: &BuiltinRegistry,
80    hook_rt: &HookEngineCtx<'_>,
81) -> Result<BTreeMap<String, SubagentProfile>, ProfileHookBuildError> {
82    specs
83        .iter()
84        .map(|(name, spec)| {
85            let hooks = if spec.hooks.is_empty() {
86                None
87            } else {
88                let engine = build_engine_arc(&spec.hooks, builtins, hook_rt).map_err(|err| {
89                    ProfileHookBuildError {
90                        profile: name.clone(),
91                        source: err,
92                    }
93                })?;
94                Some(engine as Arc<dyn HookEngine>)
95            };
96            Ok((
97                name.clone(),
98                SubagentProfile {
99                    description: spec.description.clone(),
100                    model: spec.model.clone(),
101                    system_prompt: spec.system_prompt_text.clone(),
102                    tool_allow: spec.tool_allow.clone(),
103                    sampling: spec.sampling.clone(),
104                    inherit_project_prompt: spec.inherit_project_prompt,
105                    request_limit: spec.request_limit,
106                    hooks,
107                },
108            ))
109        })
110        .collect()
111}
112
113/// Hook engine build failed for a subagent profile; include the profile name for
114/// identification.
115#[derive(Debug, thiserror::Error)]
116#[error("subagent profile `{profile}` hook engine build failed: {source}")]
117pub struct ProfileHookBuildError {
118    pub profile: String,
119    #[source]
120    pub source: HookEngineBuildError,
121}
122
123/// Project [`SkillSpec`] from `defect-config` into the agent-side [`SkillEntry`],
124/// mirroring the cross-crate assembly-boundary projection pattern used in
125/// `project_profiles`.
126pub fn project_skills(specs: &BTreeMap<String, SkillSpec>) -> BTreeMap<String, SkillEntry> {
127    specs
128        .iter()
129        .map(|(name, spec)| {
130            (
131                name.clone(),
132                SkillEntry {
133                    description: spec.description.clone(),
134                    body: spec.body.clone(),
135                    dir: spec.dir.clone(),
136                    always: spec.always,
137                    triggers: spec.triggers.clone(),
138                },
139            )
140        })
141        .collect()
142}
143
144/// Assembles the process tool set, overlaying `spawn_agent` and `skill` tools when
145/// profiles or skills are present.
146///
147/// Composition: first build the base tool set (bash/fs/fetch/search), then place
148/// `spawn_agent` (when any profile is found) and `skill` (when any skill is found) into
149/// an overlay registry, and combine them with [`CompositeRegistry`] on top of the base.
150///
151/// - `spawn_agent`'s "child tool source" is the **base tool set** (without these overlay
152///   tools), so child agents structurally cannot access `spawn_agent`—preventing
153///   recursion; they also cannot access `skill` (skill is a top-level agent capability;
154///   child agents use their own profile prompt); similarly they cannot access
155///   `inspect_background_task` / `cancel_background_task` (the background task table
156///   belongs to the top-level session, and child agents' nested turns have
157///   `ToolContext::background` as `None`).
158/// - When both profiles and skills are empty, no overlay is applied and the pure base is
159///   returned.
160///
161/// `base_prompt` is inherited by child agents (the "you are an agent that uses tools"
162/// base prompt); the profile's role prompt is appended separately.
163///
164/// `builtins` / `hook_rt` are used to compile each profile's `[hooks]` into a hook engine
165/// (see `project_profiles`)—a child agent's hooks are part of its identity and are not
166/// inherited from the parent.
167///
168/// # Errors
169/// If any profile's hook engine fails to build, it is a hard failure
170/// ([`ProfileHookBuildError`]).
171// This is a boundary assembly function: its parameters are the individual components of
172// `AgentCore`; extracting them into a struct would fragment the call site (in `cli.rs`,
173// they are passed one by one), so two extra hook-assembly dependencies are kept inline.
174#[allow(clippy::too_many_arguments)]
175pub fn build_process_tools_with_subagents(
176    config: &LoadedConfig,
177    profiles: &BTreeMap<String, ProfileSpec>,
178    skills: &BTreeMap<String, SkillEntry>,
179    registry: &Arc<ProviderRegistry>,
180    policy: &Arc<dyn SandboxPolicy>,
181    base_prompt: Option<String>,
182    builtins: &BuiltinRegistry,
183    hook_rt: &HookEngineCtx<'_>,
184) -> Result<Arc<dyn ToolRegistry>, ProfileHookBuildError> {
185    let base = build_process_tools(config);
186    let projected = project_profiles(profiles, builtins, hook_rt)?;
187    let has_profiles = SpawnAgentTool::has_profiles(&projected);
188    let has_skills = SkillTool::has_skills(skills);
189    if !has_profiles && !has_skills {
190        return Ok(base);
191    }
192
193    let mut overlay = StaticToolRegistry::builder();
194    if has_profiles {
195        let spawn = SpawnAgentTool::new(
196            Arc::new(projected),
197            registry.clone(),
198            policy.clone(),
199            base.clone(),
200            base_prompt,
201        );
202        overlay = overlay.insert(Arc::new(spawn));
203        // Background task control surface: query progress / early cancellation. Same tier
204        // as `spawn_agent` — only meaningful when the agent can spawn background
205        // subagents (`has_profiles`), and likewise only inserted into the overlay, not
206        // into the subagent's tool subset source, so subagents structurally cannot reach
207        // it (same reasoning as disabling recursion).
208        overlay = overlay.insert(Arc::new(InspectBackgroundTaskTool::new()));
209        overlay = overlay.insert(Arc::new(CancelBackgroundTaskTool::new()));
210    }
211    if has_skills {
212        let skill = SkillTool::new(Arc::new(skills.clone()));
213        overlay = overlay.insert(Arc::new(skill));
214    }
215    let overlay_reg: Arc<dyn ToolRegistry> = Arc::new(overlay.build());
216    Ok(Arc::new(CompositeRegistry::new(overlay_reg, base)))
217}