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}