1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
//! Per-turn tool registry setup.
//!
//! This keeps mode/feature-specific registry construction out of the send path.
use super::*;
use crate::core::authority::shell_policy_for_mode;
use crate::tools::AgentToolSurfaceOptions;
use crate::worker_profile::ShellPolicy;
fn should_register_remember_tool(memory_enabled: bool) -> bool {
memory_enabled
}
impl Engine {
pub(super) fn agent_tool_surface_options(
&self,
shell_policy: ShellPolicy,
) -> AgentToolSurfaceOptions {
let mut options = AgentToolSurfaceOptions::new(shell_policy);
options.apply_patch_enabled = self.config.features.enabled(Feature::ApplyPatch);
options.web_search_enabled = self.config.features.enabled(Feature::WebSearch);
options.memory_tool_enabled = should_register_remember_tool(self.config.memory_enabled);
options.vision_config = if self.config.features.enabled(Feature::VisionModel) {
self.config.vision_config.clone()
} else {
None
};
options.speech_output_dir = self.config.speech_output_dir.clone();
options.goal_state = Some(self.config.goal_state.clone());
options.verify_tool_enabled = self.config.features.enabled(Feature::Verify);
options
}
#[cfg(test)]
pub(super) fn build_turn_tool_registry_builder(
&self,
mode: AppMode,
todo_list: SharedTodoList,
plan_state: SharedPlanState,
) -> ToolRegistryBuilder {
self.build_turn_tool_registry_builder_for_route(
mode,
self.session.allow_shell,
self.deepseek_client.clone(),
&self.session.model,
todo_list,
plan_state,
)
}
/// Build the registry from the route and authority already resolved for
/// this turn. Preview calls this before either is installed on the engine,
/// so reading `self.session` here would describe the previous turn's shell
/// posture, client, and model.
#[allow(clippy::too_many_arguments)]
pub(super) fn build_turn_tool_registry_builder_for_route(
&self,
mode: AppMode,
allow_shell: bool,
client: Option<DeepSeekClient>,
model: &str,
todo_list: SharedTodoList,
plan_state: SharedPlanState,
) -> ToolRegistryBuilder {
let shell_policy = shell_policy_for_mode(mode, allow_shell);
if mode != AppMode::Plan {
let mut builder = ToolRegistryBuilder::new().with_agent_runtime_surface(
client.clone(),
model.to_string(),
self.agent_tool_surface_options(shell_policy),
todo_list,
plan_state,
);
if self.config.features.enabled(Feature::Mcp) {
builder = builder.with_registry_mcp_sync_tool();
}
// `start_mcp_server` belongs to every executable mode. Keep its
// handler aligned with the model catalog, which always loads the
// tool while MCP is enabled. The former early return registered
// it only in Plan mode, so Agent/Full Access advertised a tool
// that could never cross the execution boundary.
if let Some(ref pool) = self.mcp_pool {
builder = builder
.with_runtime_mcp_tool(Arc::clone(pool))
.with_registry_mcp_start_tool(Arc::clone(pool));
}
return builder;
}
let mut builder = ToolRegistryBuilder::new()
// Modes change authority, not the primitive tool identity.
// Plan advertises the same file names as Work; the turn-loop
// mode gate below the schema boundary blocks write/edit, and
// the ToolContext carries ShellPolicy::None.
.with_file_tools()
// Foreground-only shell registration: never add terminal/*
// lifecycle tools to Plan merely to keep the `bash` identity
// stable across modes.
.with_foreground_shell_tools()
.with_search_tools()
.with_git_tools()
.with_git_history_tools()
.with_diagnostics_tool()
.with_skill_tools()
.with_validation_tools()
.with_handle_tools()
.with_runtime_read_only_task_tools()
.with_todo_tool(todo_list)
.with_plan_tool(plan_state)
.with_goal_tools(self.config.goal_state.clone());
builder = builder
.with_review_tool(client, model.to_string())
.with_user_input_tool();
if self.config.features.enabled(Feature::WebSearch) {
builder = builder.with_web_tools();
}
// Register the `remember` tool only when the user has opted in to
// user-memory (#489). Without that opt-in the tool would always
// fail; surfacing it would just waste catalog slots.
if should_register_remember_tool(self.config.memory_enabled) {
builder = builder.with_remember_tool();
}
// Register image_analyze tool when vision_model is configured and feature enabled.
if self.config.features.enabled(Feature::VisionModel)
&& let Some(ref vision_config) = self.config.vision_config
{
builder = builder.with_vision_tools(vision_config.clone());
}
// Register the `notify` tool unconditionally (#1322). It has no
// side effects beyond a single terminal escape write and respects
// the user's `[notifications].method` config (including `off`),
// so there's no failure mode worth gating on.
builder = builder.with_notify_tool();
// Register the `registry_sync` tool for fetching and caching
// MCP Registry server metadata. Rides on `Feature::Mcp` — the same
// flag that gates the rest of the MCP system (defaults to enabled;
// opt out via `[features]` in config.toml).
if self.config.features.enabled(Feature::Mcp) {
builder = builder.with_registry_mcp_sync_tool();
}
// Register the start_mcp_server tool so LLM can dynamically start
// MCP servers from conversation context. Only when the pool has been
// initialized (lazy via ensure_mcp_pool).
if let Some(ref pool) = self.mcp_pool {
builder = builder
.with_runtime_mcp_tool(Arc::clone(pool))
.with_registry_mcp_start_tool(Arc::clone(pool));
}
builder
}
}
#[cfg(test)]
mod tests {
use super::should_register_remember_tool;
#[test]
fn remember_tool_registration_requires_memory_opt_in() {
assert!(should_register_remember_tool(true));
assert!(!should_register_remember_tool(false));
}
}