Skip to main content

nanocodex_tools/runtime/
selection.rs

1use super::*;
2
3/// Nanocodex's model-visible tool exposure policy.
4#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
5pub enum ToolExposure {
6    /// Expose normal tools only through Code Mode's `exec` entrypoint.
7    #[default]
8    CodeModeOnly,
9    /// Expose `exec` and `wait` before ordinary direct tools, while retaining
10    /// the same handlers for calls composed through Code Mode.
11    DirectAndCodeMode,
12    /// Expose a tool directly without making it callable from Code Mode.
13    DirectOnly,
14    /// Keep a tool registered for dispatch without exposing it to the model.
15    Hidden,
16}
17
18impl ToolExposure {
19    pub(super) const fn is_direct(self) -> bool {
20        matches!(self, Self::DirectAndCodeMode | Self::DirectOnly)
21    }
22
23    pub(super) const fn is_available_in_code_mode(self) -> bool {
24        matches!(self, Self::CodeModeOnly | Self::DirectAndCodeMode)
25    }
26}
27
28#[derive(Clone)]
29pub(super) struct RegisteredTool {
30    pub(super) handler: Arc<dyn Tool>,
31    pub(super) exposure: Option<ToolExposure>,
32}
33
34/// A lazily populated family of Code Mode tools.
35///
36/// Providers start with the agent driver, advertise only their small direct
37/// tool surface initially, and may make additional tools callable at runtime.
38#[async_trait]
39pub trait DynamicToolProvider: Send + Sync {
40    /// Starts background discovery or connection work. Implementations must be idempotent.
41    fn start(&self);
42
43    /// Returns the provider's always-visible tools, such as `tool_search`.
44    fn direct_tools(&self) -> Vec<Arc<dyn Tool>>;
45
46    /// Returns the provider's direct tools for one model exposure policy.
47    ///
48    /// Providers normally expose the same tools under either policy. In
49    /// particular, discovery entrypoints such as `tool_search` remain visible
50    /// while the tools they discover stay deferred.
51    fn direct_tools_for_exposure(&self, _exposure: ToolExposure) -> Vec<Arc<dyn Tool>> {
52        self.direct_tools()
53    }
54
55    /// Returns deferred tools currently callable from new Code Mode cells.
56    fn available_definitions(&self) -> Vec<ToolDefinition>;
57
58    /// Returns compact, stable guidance for provider tools that should be
59    /// discoverable before the model starts its first Code Mode cell.
60    ///
61    /// The complete definitions remain runtime-only and available through
62    /// `ALL_TOOLS`; summaries keep large dynamic schemas out of the model
63    /// request prefix.
64    fn code_mode_tool_summaries(&self) -> Vec<(String, String)> {
65        Vec::new()
66    }
67
68    /// Returns whether this provider currently exposes `name`.
69    fn contains(&self, name: &str) -> bool {
70        self.available_definitions()
71            .iter()
72            .any(|definition| definition.name() == name)
73    }
74
75    /// Returns whether a callable deferred tool is safe to execute in parallel.
76    ///
77    /// Providers are conservative by default. Implementations must return
78    /// `true` only for a currently callable tool with explicit safety
79    /// metadata.
80    fn supports_parallel_tool_calls(&self, _name: &str) -> bool {
81        false
82    }
83
84    /// Executes a callable deferred tool, or returns `None` when this provider
85    /// does not currently expose `name`.
86    ///
87    /// The owning runtime converts handler panics into a failed `aborted`
88    /// output; they never unwind through the runtime owner.
89    async fn execute(
90        &self,
91        name: &str,
92        input: Value,
93        context: ToolContext<'_>,
94    ) -> Option<ToolOutput>;
95}
96
97/// Declarative selection of the built-in tools installed for an agent.
98#[derive(Clone)]
99pub struct Tools {
100    exposure: ToolExposure,
101    workspace: bool,
102    web_search: bool,
103    image_generation: bool,
104    pub(super) working_directory: Option<Arc<str>>,
105    pub(super) default_shell: Option<Arc<str>>,
106    process_environment: Arc<Vec<(OsString, OsString)>>,
107    remote_http_client: Option<reqwest::Client>,
108    pub(super) registered: Vec<RegisteredTool>,
109    pub(super) provider_direct: Vec<Arc<dyn Tool>>,
110    pub(super) providers: Vec<Arc<dyn DynamicToolProvider>>,
111    pub(super) deferred_tools_guidance_enabled: bool,
112}
113
114impl Default for Tools {
115    fn default() -> Self {
116        Self {
117            exposure: ToolExposure::default(),
118            workspace: true,
119            web_search: true,
120            image_generation: true,
121            working_directory: None,
122            default_shell: None,
123            process_environment: Arc::new(Vec::new()),
124            remote_http_client: None,
125            registered: Vec::new(),
126            provider_direct: Vec::new(),
127            providers: Vec::new(),
128            deferred_tools_guidance_enabled: false,
129        }
130    }
131}
132
133impl fmt::Debug for Tools {
134    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
135        let remote_http_client_configured = self.remote_http_client.is_some();
136        formatter
137            .debug_struct("Tools")
138            .field("exposure", &self.exposure)
139            .field("workspace", &self.workspace)
140            .field("web_search", &self.web_search)
141            .field("image_generation", &self.image_generation)
142            .field("working_directory", &self.working_directory)
143            .field("default_shell", &self.default_shell)
144            .field("process_environment_count", &self.process_environment.len())
145            .field(
146                "remote_http_client_configured",
147                &remote_http_client_configured,
148            )
149            .field(
150                "registered",
151                &self
152                    .registered
153                    .iter()
154                    .map(|tool| tool.handler.definition().name().to_owned())
155                    .collect::<Vec<_>>(),
156            )
157            .field(
158                "provider_direct",
159                &self
160                    .provider_direct
161                    .iter()
162                    .map(|tool| tool.definition().name().to_owned())
163                    .collect::<Vec<_>>(),
164            )
165            .field("provider_count", &self.providers.len())
166            .finish()
167    }
168}
169
170impl Tools {
171    /// Starts a builder with all standard tools enabled.
172    #[must_use]
173    pub fn builder() -> ToolsBuilder {
174        ToolsBuilder::default()
175    }
176
177    /// Resumes configuring this tool selection while preserving its built-ins,
178    /// registered tools, and dynamic providers.
179    #[must_use]
180    pub const fn into_builder(self) -> ToolsBuilder {
181        ToolsBuilder { tools: self }
182    }
183
184    /// Returns the model-visible tool exposure policy.
185    #[must_use]
186    pub const fn exposure(&self) -> ToolExposure {
187        self.exposure
188    }
189
190    /// Returns whether the standard workspace tools are enabled.
191    #[must_use]
192    pub const fn workspace_enabled(&self) -> bool {
193        self.workspace
194    }
195
196    /// Returns whether the standard web-search tool is enabled.
197    #[must_use]
198    pub const fn web_search_enabled(&self) -> bool {
199        self.web_search
200    }
201
202    /// Returns whether the standard image-generation tool is enabled.
203    #[must_use]
204    pub const fn image_generation_enabled(&self) -> bool {
205        self.image_generation
206    }
207
208    /// Returns this tool selection bound to one agent session.
209    ///
210    /// Native workspace commands receive the session ID through
211    /// `CODEX_THREAD_ID`. This binding replaces a caller-provided value without
212    /// mutating other clones of the tool selection.
213    #[must_use]
214    pub fn for_session(mut self, session_id: &str) -> Self {
215        self.insert_process_environment(CODEX_THREAD_ID_ENV_VAR.into(), session_id.into());
216        self
217    }
218
219    pub(super) fn process_environment(&self) -> Arc<Vec<(OsString, OsString)>> {
220        Arc::clone(&self.process_environment)
221    }
222
223    fn insert_process_environment(&mut self, name: OsString, value: OsString) {
224        let environment = Arc::make_mut(&mut self.process_environment);
225        environment.retain(|(candidate, _)| candidate != &name);
226        environment.push((name, value));
227    }
228
229    pub(super) fn remote_http_client(&self) -> Option<reqwest::Client> {
230        self.remote_http_client.clone()
231    }
232
233    /// Starts all dynamic providers without waiting for their handshakes.
234    pub fn start_providers(&self) {
235        for provider in &self.providers {
236            provider.start();
237        }
238    }
239}
240
241/// Builder for the built-in tool selection.
242#[derive(Default)]
243pub struct ToolsBuilder {
244    tools: Tools,
245}
246
247/// Invalid declarative tool selection.
248#[derive(Debug, thiserror::Error)]
249pub enum ToolsBuildError {
250    /// A custom definition has an empty registry name.
251    #[error("tool name must not be empty")]
252    EmptyName,
253
254    /// The model-visible working-directory override is empty.
255    #[error("working directory override must not be empty")]
256    EmptyWorkingDirectory,
257
258    /// The model-visible shell override is empty.
259    #[error("default shell override must not be empty")]
260    EmptyDefaultShell,
261
262    /// Two custom tools use the same definition name.
263    #[error("tool name `{0}` is registered more than once")]
264    DuplicateName(Box<str>),
265
266    /// A custom tool collides with an enabled built-in tool.
267    #[error("tool name `{0}` conflicts with an enabled built-in tool")]
268    BuiltInName(Box<str>),
269
270    /// A custom tool collides with a host-owned routing tool.
271    #[error("tool name `{0}` is reserved by the Code Mode host")]
272    ReservedName(Box<str>),
273}
274
275impl ToolsBuilder {
276    /// Selects whether registered tools are also exposed directly to the model.
277    ///
278    /// The default is [`ToolExposure::CodeModeOnly`]. This changes only the
279    /// model-visible declaration set; all registered handlers remain callable
280    /// from Code Mode.
281    #[must_use]
282    pub const fn exposure(mut self, exposure: ToolExposure) -> Self {
283        self.tools.exposure = exposure;
284        self
285    }
286
287    /// Starts from an empty built-in tool set.
288    #[must_use]
289    pub const fn without_defaults(mut self) -> Self {
290        self.tools.workspace = false;
291        self.tools.web_search = false;
292        self.tools.image_generation = false;
293        self
294    }
295
296    /// Enables or disables the standard command, patch, plan, and file tools.
297    #[must_use]
298    pub const fn workspace(mut self, enabled: bool) -> Self {
299        self.tools.workspace = enabled;
300        self
301    }
302
303    /// Enables or disables the built-in direct web-search tool.
304    #[must_use]
305    pub const fn web_search(mut self, enabled: bool) -> Self {
306        self.tools.web_search = enabled;
307        self
308    }
309
310    /// Enables or disables the built-in image-generation tool.
311    #[must_use]
312    pub const fn image_generation(mut self, enabled: bool) -> Self {
313        self.tools.image_generation = enabled;
314        self
315    }
316
317    /// Overrides the default working directory described to the model.
318    #[must_use]
319    pub fn working_directory(mut self, directory: impl Into<Arc<str>>) -> Self {
320        self.tools.working_directory = Some(directory.into());
321        self
322    }
323
324    /// Overrides the default shell described to the model.
325    #[must_use]
326    pub fn default_shell(mut self, shell: impl Into<Arc<str>>) -> Self {
327        self.tools.default_shell = Some(shell.into());
328        self
329    }
330
331    /// Adds explicit environment overrides to workspace-tool child processes.
332    ///
333    /// Overrides are scoped to commands spawned by this tool selection and do
334    /// not mutate the embedding process. A later value for the same name wins.
335    #[must_use]
336    pub fn process_environment<I, K, V>(mut self, variables: I) -> Self
337    where
338        I: IntoIterator<Item = (K, V)>,
339        K: Into<OsString>,
340        V: Into<OsString>,
341    {
342        for (name, value) in variables {
343            self.tools
344                .insert_process_environment(name.into(), value.into());
345        }
346        self
347    }
348
349    /// Overrides the HTTP client used by in-process remote tools.
350    #[must_use]
351    pub fn remote_http_client(mut self, client: reqwest::Client) -> Self {
352        self.tools.remote_http_client = Some(client);
353        self
354    }
355
356    /// Adds a function or freeform tool to the runtime.
357    #[must_use]
358    pub fn tool<T: Tool + 'static>(mut self, tool: T) -> Self {
359        self.tools.registered.push(RegisteredTool {
360            handler: Arc::new(tool),
361            exposure: None,
362        });
363        self
364    }
365
366    /// Adds a function or freeform tool with an explicit model-facing exposure.
367    #[must_use]
368    pub fn tool_with_exposure<T: Tool + 'static>(
369        mut self,
370        tool: T,
371        exposure: ToolExposure,
372    ) -> Self {
373        self.tools.registered.push(RegisteredTool {
374            handler: Arc::new(tool),
375            exposure: Some(exposure),
376        });
377        self
378    }
379
380    /// Adds a dynamic family of Code Mode tools.
381    #[must_use]
382    pub fn provider<P: DynamicToolProvider + 'static>(mut self, provider: P) -> Self {
383        let provider: Arc<dyn DynamicToolProvider> = Arc::new(provider);
384        self.tools.providers.push(provider);
385        self.refresh_provider_direct();
386        self
387    }
388
389    /// Validates tool names and finishes the runtime configuration.
390    ///
391    /// # Errors
392    ///
393    /// Returns an error for empty, duplicate, or enabled built-in tool names.
394    pub fn build(mut self) -> Result<Tools, ToolsBuildError> {
395        self.refresh_provider_direct();
396        if self
397            .tools
398            .working_directory
399            .as_deref()
400            .is_some_and(|directory| directory.trim().is_empty())
401        {
402            return Err(ToolsBuildError::EmptyWorkingDirectory);
403        }
404        if self
405            .tools
406            .default_shell
407            .as_deref()
408            .is_some_and(|shell| shell.trim().is_empty())
409        {
410            return Err(ToolsBuildError::EmptyDefaultShell);
411        }
412        let mut names = HashSet::with_capacity(
413            self.tools
414                .registered
415                .len()
416                .saturating_add(self.tools.provider_direct.len()),
417        );
418        for tool in &self.tools.registered {
419            let definition = tool.handler.definition();
420            let name = definition.name();
421            if name.is_empty() {
422                return Err(ToolsBuildError::EmptyName);
423            }
424            if host_owned_name(name)
425                || (name == "tool_search"
426                    && !matches!(definition, ToolDefinition::ToolSearch { .. }))
427            {
428                return Err(ToolsBuildError::ReservedName(name.into()));
429            }
430            if built_in_name(&self.tools, name) {
431                return Err(ToolsBuildError::BuiltInName(name.into()));
432            }
433            if !names.insert(name.to_owned()) {
434                return Err(ToolsBuildError::DuplicateName(name.into()));
435            }
436        }
437        for tool in &self.tools.provider_direct {
438            let definition = tool.definition();
439            let name = definition.name();
440            if name.is_empty() {
441                return Err(ToolsBuildError::EmptyName);
442            }
443            if host_owned_name(name) {
444                return Err(ToolsBuildError::ReservedName(name.into()));
445            }
446            if built_in_name(&self.tools, name) {
447                return Err(ToolsBuildError::BuiltInName(name.into()));
448            }
449            if !names.insert(name.to_owned()) {
450                return Err(ToolsBuildError::DuplicateName(name.into()));
451            }
452        }
453        Ok(self.tools)
454    }
455
456    fn refresh_provider_direct(&mut self) {
457        self.tools.deferred_tools_guidance_enabled = self.tools.providers.iter().any(|provider| {
458            provider
459                .direct_tools()
460                .iter()
461                .any(|tool| matches!(tool.definition(), ToolDefinition::ToolSearch { .. }))
462        });
463        self.tools.provider_direct = self
464            .tools
465            .providers
466            .iter()
467            .flat_map(|provider| provider.direct_tools_for_exposure(self.tools.exposure))
468            .collect();
469    }
470}
471
472fn built_in_name(tools: &Tools, name: &str) -> bool {
473    (tools.workspace
474        && matches!(
475            name,
476            "exec_command" | "write_stdin" | "update_plan" | "apply_patch" | "view_image"
477        ))
478        || (tools.web_search && name == "web__run")
479        || (tools.image_generation && name == "image_gen__imagegen")
480}