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}